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/telegram/daemon.py
ADDED
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
"""TelegramDaemon: the business logic behind `pcli telegram` - see cli.py's
|
|
2
|
+
run_telegram_daemon for the actual python-telegram-bot wiring, which is
|
|
3
|
+
thin glue around this class's public methods (not itself heavily unit
|
|
4
|
+
tested against a fake PTB - the same division of labor browser/session.py
|
|
5
|
+
draws between its own control flow and Playwright's raw calls).
|
|
6
|
+
|
|
7
|
+
Owns the one ongoing Session for the authorized chat and processes
|
|
8
|
+
incoming text messages one at a time through a queue - mirrors
|
|
9
|
+
ChatScreen's own "don't run two turns at once" discipline
|
|
10
|
+
(_turn_in_progress / queued followups), just as an explicit asyncio.Queue
|
|
11
|
+
instead of a Textual worker loop, so a message that arrives mid-turn waits
|
|
12
|
+
its turn instead of racing the one already running (both would otherwise
|
|
13
|
+
read/mutate the same Session.messages list concurrently). `!`-prefixed
|
|
14
|
+
shell passthrough is the one exception - it never touches the Session at
|
|
15
|
+
all, so it runs immediately instead of queuing (see handle_text).
|
|
16
|
+
|
|
17
|
+
A fresh Session is started each time the daemon starts (same default `pcli
|
|
18
|
+
run` uses with no --session) - restarting `pcli telegram` begins a new
|
|
19
|
+
conversation. /new resets it without restarting the daemon.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
import secrets
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import replace
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from pcli.agent.compaction import maybe_compact
|
|
32
|
+
from pcli.agent.headless import new_headless_session, run_headless_task
|
|
33
|
+
from pcli.agent.runtime import AgentRuntime, make_tool_context
|
|
34
|
+
from pcli.config.settings import Settings, remove_config_keys, update_config_file
|
|
35
|
+
from pcli.cost.context import (
|
|
36
|
+
ContextLimitTable,
|
|
37
|
+
compute_max_response_tokens,
|
|
38
|
+
current_context_usage,
|
|
39
|
+
set_model_context_limit,
|
|
40
|
+
)
|
|
41
|
+
from pcli.cost.tracker import CostTracker
|
|
42
|
+
from pcli.llm.errors import GatewayError
|
|
43
|
+
from pcli.memory.extraction import extract_memory
|
|
44
|
+
from pcli.memory.models import render_memory_list
|
|
45
|
+
from pcli.memory.store import clear_memory, read_memory, remove_entry
|
|
46
|
+
from pcli.permissions.guardrails import update_guardrails_fs_allowed_roots, update_guardrails_limits
|
|
47
|
+
from pcli.permissions.manager import PermissionManager
|
|
48
|
+
from pcli.sandbox.base import SandboxSecurityError
|
|
49
|
+
from pcli.session.models import ToolInvocation
|
|
50
|
+
from pcli.session.store import SessionStore
|
|
51
|
+
from pcli.telegram.permissions import (
|
|
52
|
+
PendingApprovals,
|
|
53
|
+
TelegramSender,
|
|
54
|
+
ask_via_telegram,
|
|
55
|
+
decode_callback_data,
|
|
56
|
+
)
|
|
57
|
+
from pcli.tools.artifacts import SessionArtifactStore
|
|
58
|
+
from pcli.tools.toolbox.manager import ToolboxDiscoveryError
|
|
59
|
+
from pcli.tui.shell_passthrough import run_passthrough_command
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
_MAX_MODEL_BUTTONS = 20
|
|
64
|
+
"""Caps the /models inline-button listing the same way /sessions caps its
|
|
65
|
+
own text listing - a long gateway model list would otherwise produce an
|
|
66
|
+
unwieldy wall of one-button-per-row inline buttons."""
|
|
67
|
+
|
|
68
|
+
_HELP_TEXT = (
|
|
69
|
+
"Commands:\n"
|
|
70
|
+
"\n"
|
|
71
|
+
"/help - show this help.\n"
|
|
72
|
+
"/new - start a fresh session (does not restart the daemon).\n"
|
|
73
|
+
"/rename [name] - view or set the current session's title.\n"
|
|
74
|
+
"/timeout [seconds] - view or set the per-request gateway timeout.\n"
|
|
75
|
+
"/temperature [value|off] - view or set the sampling temperature.\n"
|
|
76
|
+
"/budget [amount|off] - view or set a hard cap on this session's spend.\n"
|
|
77
|
+
"/context_limit [tokens] - view or set the assumed context window for the current model.\n"
|
|
78
|
+
"/max_tool_iterations [n] - view or set the per-turn tool-call iteration cap.\n"
|
|
79
|
+
"/artifact_threshold [chars] - view or set the tool-output length archived instead of "
|
|
80
|
+
"kept inline.\n"
|
|
81
|
+
"/max_tool_calls_per_turn [n] - view or set the per-turn tool-call guardrail (0 = "
|
|
82
|
+
"unlimited).\n"
|
|
83
|
+
"/max_tool_calls_per_minute [n] - view or set the per-minute tool-call guardrail (0 = "
|
|
84
|
+
"unlimited).\n"
|
|
85
|
+
"/prune_tool_results [off|on|n] - view, toggle, or set tool-result pruning.\n"
|
|
86
|
+
"/max_response_tokens [off|on|margin] - view, toggle, or set the dynamic max_tokens cap.\n"
|
|
87
|
+
"/allowed_roots [add|remove] [path] - view or edit the filesystem guardrail's allowed "
|
|
88
|
+
"paths.\n"
|
|
89
|
+
"/memory [forget <id>|clear] - view, trim, or clear pcli's cross-session memory of you.\n"
|
|
90
|
+
"/toolbox [discover <name> [path]|list|remove <name>] - discover, list, or remove "
|
|
91
|
+
"toolbox tools (CLI programs/scripts wrapped as callable tools).\n"
|
|
92
|
+
"/compact - manually summarize the conversation so far to free up context space.\n"
|
|
93
|
+
"/models [name] - set the model, or list what's available from the gateway.\n"
|
|
94
|
+
"/sessions [switch <id>] - list other sessions, or switch to one.\n"
|
|
95
|
+
"/export [path] - export the current session and send it as a document.\n"
|
|
96
|
+
"/plan - enter plan mode: only read-only/exploration tools are available.\n"
|
|
97
|
+
"/build - exit plan mode, restoring full tool access.\n"
|
|
98
|
+
"\n"
|
|
99
|
+
"!<command> - run a shell command directly, bypassing the agent (!!<command> hides the "
|
|
100
|
+
"output).\n"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _CommandError(Exception):
|
|
105
|
+
"""Raised by a scalar-setting command's `apply` closure (see
|
|
106
|
+
TelegramDaemon._handle_scalar_setting_command) to report bad input -
|
|
107
|
+
caught there and sent back as the reply, instead of propagating to
|
|
108
|
+
run_forever's generic "something went wrong" handler. Not a GatewayError
|
|
109
|
+
or anything network-related; purely a validation-failure message."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _encode_model_callback(token: str) -> str:
|
|
113
|
+
return f"model:{token}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _decode_model_callback(data: str) -> str | None:
|
|
117
|
+
"""None if data isn't one of ours - mirrors permissions.py's own
|
|
118
|
+
decode_callback_data (a "perm:"-prefixed payload), a separate,
|
|
119
|
+
parallel namespace rather than overloading that one, so
|
|
120
|
+
handle_callback can simply try both."""
|
|
121
|
+
prefix, _, token = data.partition(":")
|
|
122
|
+
if prefix != "model" or not token:
|
|
123
|
+
return None
|
|
124
|
+
return token
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TelegramDaemon:
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
settings: Settings,
|
|
132
|
+
runtime: AgentRuntime,
|
|
133
|
+
permission_manager: PermissionManager,
|
|
134
|
+
store: SessionStore,
|
|
135
|
+
cwd: Path,
|
|
136
|
+
sender: TelegramSender,
|
|
137
|
+
) -> None:
|
|
138
|
+
self._settings = settings
|
|
139
|
+
self._runtime = runtime
|
|
140
|
+
self._permission_manager = permission_manager
|
|
141
|
+
self._store = store
|
|
142
|
+
self._cwd = cwd
|
|
143
|
+
self._sender = sender
|
|
144
|
+
self._pending_approvals = PendingApprovals()
|
|
145
|
+
self._chat_id = settings.telegram_chat_id
|
|
146
|
+
self._session = new_headless_session(store, settings, cwd)
|
|
147
|
+
self._queue: asyncio.Queue[str] = asyncio.Queue()
|
|
148
|
+
self._context_limit_table = ContextLimitTable.load()
|
|
149
|
+
self._turn_in_progress = False
|
|
150
|
+
self._model_choices: dict[str, str] = {}
|
|
151
|
+
self._plan_mode = False
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def session_id(self) -> str:
|
|
155
|
+
return self._session.id
|
|
156
|
+
|
|
157
|
+
def _is_authorized(self, chat_id: int) -> bool:
|
|
158
|
+
# Personal automation, not a multi-user bot (see Settings.
|
|
159
|
+
# telegram_chat_id) - anything from another chat is silently
|
|
160
|
+
# ignored, not just refused, so this bot doesn't even confirm to a
|
|
161
|
+
# stranger that it exists and is listening.
|
|
162
|
+
return chat_id == self._chat_id
|
|
163
|
+
|
|
164
|
+
async def handle_text(self, chat_id: int, text: str) -> None:
|
|
165
|
+
"""Queues a message for processing - never runs it inline, except
|
|
166
|
+
for `!`-prefixed shell passthrough (see handle_shell_passthrough),
|
|
167
|
+
which bypasses the queue/session/LLM entirely, mirroring the TUI's
|
|
168
|
+
own immediate, turn-independent handling of `!command` in
|
|
169
|
+
ChatScreen.on_chat_input_submitted. See the module docstring for why
|
|
170
|
+
queuing everything else matters."""
|
|
171
|
+
if not self._is_authorized(chat_id):
|
|
172
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
173
|
+
return
|
|
174
|
+
if text.startswith("!"):
|
|
175
|
+
await self.handle_shell_passthrough(chat_id, text)
|
|
176
|
+
return
|
|
177
|
+
await self._queue.put(text)
|
|
178
|
+
|
|
179
|
+
async def handle_shell_passthrough(self, chat_id: int, raw: str) -> None:
|
|
180
|
+
"""Mirrors ChatScreen's `!command`/`!!command` (tui/shell_passthrough.py)
|
|
181
|
+
- runs the command directly against the real environment, bypassing
|
|
182
|
+
the LLM, the sandbox, permissions, and session/artifact recording
|
|
183
|
+
entirely. Safe to expose here under the same trust model as the TUI
|
|
184
|
+
version: the chat-id authorization check above is this bot's
|
|
185
|
+
equivalent of "the user's own keyboard" (see the module docstring's
|
|
186
|
+
"personal automation, not a multi-user bot"). The TUI's `!!!`
|
|
187
|
+
(handing off a real interactive terminal) has no Telegram
|
|
188
|
+
equivalent - there's no TTY to hand off to - so it gets an
|
|
189
|
+
explanatory reply instead of being attempted."""
|
|
190
|
+
if not self._is_authorized(chat_id):
|
|
191
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if raw.startswith("!!!"):
|
|
195
|
+
await self._sender.send_message(
|
|
196
|
+
chat_id,
|
|
197
|
+
"'!!!' (an interactive terminal) isn't supported over Telegram - there's "
|
|
198
|
+
"no terminal to hand it off to. Use '!command' or '!!command' instead.",
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
quiet = raw.startswith("!!")
|
|
203
|
+
command = raw[2:].strip() if quiet else raw[1:].strip()
|
|
204
|
+
if not command:
|
|
205
|
+
await self._sender.send_message(
|
|
206
|
+
chat_id,
|
|
207
|
+
"Usage: !<command> to run a shell command (!!<command> to hide the result).",
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
result = await run_passthrough_command(command, cwd=self._cwd)
|
|
212
|
+
|
|
213
|
+
if quiet:
|
|
214
|
+
await self._sender.send_message(chat_id, f"$ {command}\n(output hidden)")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
output = result.stdout
|
|
218
|
+
if result.stderr:
|
|
219
|
+
output += f"\n--- stderr ---\n{result.stderr}"
|
|
220
|
+
footer = f"\n[exit_code={result.exit_code}]"
|
|
221
|
+
if result.timed_out:
|
|
222
|
+
footer += " (timed out)"
|
|
223
|
+
await self._sender.send_message(chat_id, f"$ {command}\n{output}{footer}")
|
|
224
|
+
|
|
225
|
+
async def _handle_scalar_setting_command(
|
|
226
|
+
self,
|
|
227
|
+
chat_id: int,
|
|
228
|
+
arg: str | None,
|
|
229
|
+
*,
|
|
230
|
+
view: Callable[[], str],
|
|
231
|
+
apply: Callable[[str], str],
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Shared by every "/command [value]" setting below (mirrors the
|
|
234
|
+
view-then-set shape of chat.py's equivalents, e.g.
|
|
235
|
+
ChatScreen._handle_timeout_command): no value -> report the current
|
|
236
|
+
one via `view`; a value -> `apply` it (parse, validate, persist,
|
|
237
|
+
return the confirmation text - or raise _CommandError for bad
|
|
238
|
+
input, same as a validation failure chat.py reports as a toast).
|
|
239
|
+
Unlike chat.py, nothing here calls self._agent_loop.set_*() to
|
|
240
|
+
apply a change immediately - TelegramDaemon has no persistent
|
|
241
|
+
AgentLoop; run_headless_task builds a fresh one from self._settings
|
|
242
|
+
on every message, so persisting the setting is already enough.
|
|
243
|
+
Same authorization gate as handle_text."""
|
|
244
|
+
if not self._is_authorized(chat_id):
|
|
245
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
reply = view() if not arg else apply(arg)
|
|
249
|
+
except _CommandError as exc:
|
|
250
|
+
await self._sender.send_message(chat_id, str(exc))
|
|
251
|
+
return
|
|
252
|
+
await self._sender.send_message(chat_id, reply)
|
|
253
|
+
|
|
254
|
+
async def handle_timeout_command(self, chat_id: int, arg: str | None) -> None:
|
|
255
|
+
def view() -> str:
|
|
256
|
+
current = self._settings.request_timeout_s
|
|
257
|
+
effective = self._settings.effective_request_timeout_s
|
|
258
|
+
note = (
|
|
259
|
+
f" (effective: {effective:g}s - floored for local-api)"
|
|
260
|
+
if effective != current
|
|
261
|
+
else ""
|
|
262
|
+
)
|
|
263
|
+
return f"request_timeout_s is currently {current:g}s{note}. Usage: /timeout <seconds>"
|
|
264
|
+
|
|
265
|
+
def apply(raw: str) -> str:
|
|
266
|
+
try:
|
|
267
|
+
seconds = float(raw)
|
|
268
|
+
except ValueError:
|
|
269
|
+
raise _CommandError(f"'{raw}' isn't a valid number of seconds.") from None
|
|
270
|
+
if seconds <= 0:
|
|
271
|
+
raise _CommandError("request_timeout_s must be greater than 0.")
|
|
272
|
+
self._settings.request_timeout_s = seconds
|
|
273
|
+
update_config_file(request_timeout_s=seconds)
|
|
274
|
+
return f"request_timeout_s set to {seconds:g}s - takes effect on the next gateway request."
|
|
275
|
+
|
|
276
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
277
|
+
|
|
278
|
+
async def handle_temperature_command(self, chat_id: int, arg: str | None) -> None:
|
|
279
|
+
def view() -> str:
|
|
280
|
+
current = self._settings.default_temperature
|
|
281
|
+
text = f"{current:g}" if current is not None else "unset (gateway/model default)"
|
|
282
|
+
return f"default_temperature is currently {text}. Usage: /temperature <value>|off"
|
|
283
|
+
|
|
284
|
+
def apply(raw: str) -> str:
|
|
285
|
+
if raw == "off":
|
|
286
|
+
self._settings.default_temperature = None
|
|
287
|
+
remove_config_keys("default_temperature")
|
|
288
|
+
return "default_temperature cleared - gateway/model default applies."
|
|
289
|
+
try:
|
|
290
|
+
value = float(raw)
|
|
291
|
+
except ValueError:
|
|
292
|
+
raise _CommandError(f"'{raw}' isn't a valid number, or 'off'.") from None
|
|
293
|
+
if value < 0:
|
|
294
|
+
raise _CommandError("Temperature must be 0 or greater.")
|
|
295
|
+
self._settings.default_temperature = value
|
|
296
|
+
update_config_file(default_temperature=value)
|
|
297
|
+
return f"default_temperature set to {value:g} - takes effect on the next turn."
|
|
298
|
+
|
|
299
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
300
|
+
|
|
301
|
+
async def handle_budget_command(self, chat_id: int, arg: str | None) -> None:
|
|
302
|
+
spent = self._session.cost.session_total_usd
|
|
303
|
+
|
|
304
|
+
def view() -> str:
|
|
305
|
+
current = self._settings.max_session_cost_usd
|
|
306
|
+
text = f"${current:.2f}" if current is not None else "unset (no cap)"
|
|
307
|
+
return (
|
|
308
|
+
f"max_session_cost_usd is currently {text} (spent so far: ${spent:.4f}). "
|
|
309
|
+
"Usage: /budget <amount>|off"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def apply(raw: str) -> str:
|
|
313
|
+
if raw == "off":
|
|
314
|
+
self._settings.max_session_cost_usd = None
|
|
315
|
+
remove_config_keys("max_session_cost_usd")
|
|
316
|
+
return "max_session_cost_usd cleared - no cap."
|
|
317
|
+
try:
|
|
318
|
+
value = float(raw)
|
|
319
|
+
except ValueError:
|
|
320
|
+
raise _CommandError(f"'{raw}' isn't a valid number, or 'off'.") from None
|
|
321
|
+
if value <= 0:
|
|
322
|
+
raise _CommandError(
|
|
323
|
+
"Budget must be greater than 0 (use /budget off to clear it)."
|
|
324
|
+
)
|
|
325
|
+
self._settings.max_session_cost_usd = value
|
|
326
|
+
update_config_file(max_session_cost_usd=value)
|
|
327
|
+
return (
|
|
328
|
+
f"max_session_cost_usd set to ${value:.2f} (spent so far: ${spent:.4f}) - "
|
|
329
|
+
"takes effect on the next round-trip."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
333
|
+
|
|
334
|
+
async def handle_context_limit_command(self, chat_id: int, arg: str | None) -> None:
|
|
335
|
+
model = self._session.model or self._settings.default_model
|
|
336
|
+
|
|
337
|
+
def view() -> str:
|
|
338
|
+
if not model:
|
|
339
|
+
raise _CommandError("No model configured to set a context limit for.")
|
|
340
|
+
current = self._context_limit_table.lookup(model)
|
|
341
|
+
return (
|
|
342
|
+
f"Assumed context limit for '{model}': {current:,} tokens. Usage: "
|
|
343
|
+
"/context_limit <tokens>"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def apply(raw: str) -> str:
|
|
347
|
+
if not model:
|
|
348
|
+
raise _CommandError("No model configured to set a context limit for.")
|
|
349
|
+
try:
|
|
350
|
+
limit = int(raw)
|
|
351
|
+
except ValueError:
|
|
352
|
+
raise _CommandError(f"'{raw}' isn't a valid number of tokens.") from None
|
|
353
|
+
if limit <= 0:
|
|
354
|
+
raise _CommandError("Context limit must be greater than 0.")
|
|
355
|
+
set_model_context_limit(model, limit)
|
|
356
|
+
self._context_limit_table = ContextLimitTable.load()
|
|
357
|
+
return f"Context limit for '{model}' set to {limit:,} tokens."
|
|
358
|
+
|
|
359
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
360
|
+
|
|
361
|
+
async def handle_max_tool_iterations_command(self, chat_id: int, arg: str | None) -> None:
|
|
362
|
+
def view() -> str:
|
|
363
|
+
current = self._settings.max_tool_iterations
|
|
364
|
+
note = " (currently uncapped: local-api mode)" if self._settings.is_local_api() else ""
|
|
365
|
+
return f"max_tool_iterations is currently {current}{note}. Usage: /max_tool_iterations <n>"
|
|
366
|
+
|
|
367
|
+
def apply(raw: str) -> str:
|
|
368
|
+
try:
|
|
369
|
+
iterations = int(raw)
|
|
370
|
+
except ValueError:
|
|
371
|
+
raise _CommandError(f"'{raw}' isn't a valid number of iterations.") from None
|
|
372
|
+
if iterations <= 0:
|
|
373
|
+
raise _CommandError("max_tool_iterations must be greater than 0.")
|
|
374
|
+
self._settings.max_tool_iterations = iterations
|
|
375
|
+
update_config_file(max_tool_iterations=iterations)
|
|
376
|
+
note = (
|
|
377
|
+
" This session is in local-api mode, so it stays uncapped until that changes."
|
|
378
|
+
if self._settings.is_local_api()
|
|
379
|
+
else " Takes effect on the next turn."
|
|
380
|
+
)
|
|
381
|
+
return f"max_tool_iterations set to {iterations}.{note}"
|
|
382
|
+
|
|
383
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
384
|
+
|
|
385
|
+
async def handle_artifact_threshold_command(self, chat_id: int, arg: str | None) -> None:
|
|
386
|
+
def view() -> str:
|
|
387
|
+
current = self._settings.artifact_threshold_chars
|
|
388
|
+
return (
|
|
389
|
+
f"artifact_threshold_chars is currently {current:,}. Usage: "
|
|
390
|
+
"/artifact_threshold <chars>"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def apply(raw: str) -> str:
|
|
394
|
+
try:
|
|
395
|
+
threshold = int(raw)
|
|
396
|
+
except ValueError:
|
|
397
|
+
raise _CommandError(f"'{raw}' isn't a valid number of characters.") from None
|
|
398
|
+
if threshold <= 0:
|
|
399
|
+
raise _CommandError("artifact_threshold_chars must be greater than 0.")
|
|
400
|
+
self._settings.artifact_threshold_chars = threshold
|
|
401
|
+
update_config_file(artifact_threshold_chars=threshold)
|
|
402
|
+
return (
|
|
403
|
+
f"artifact_threshold_chars set to {threshold:,}. Takes effect on the next "
|
|
404
|
+
"tool result."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
408
|
+
|
|
409
|
+
async def _handle_guardrail_rate_limit_command(
|
|
410
|
+
self,
|
|
411
|
+
chat_id: int,
|
|
412
|
+
arg: str | None,
|
|
413
|
+
*,
|
|
414
|
+
attr_name: str,
|
|
415
|
+
config_key: str,
|
|
416
|
+
command_name: str,
|
|
417
|
+
window: str,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Shared by handle_max_tool_calls_per_turn_command and
|
|
420
|
+
handle_max_tool_calls_per_minute_command - mirrors chat.py's own
|
|
421
|
+
_handle_guardrail_rate_limit_command (both view/set the same-shaped
|
|
422
|
+
guardrails.toml [limits] key, where 0 means unlimited)."""
|
|
423
|
+
guardrails = self._permission_manager.guardrails
|
|
424
|
+
|
|
425
|
+
def view() -> str:
|
|
426
|
+
current = getattr(guardrails, attr_name)
|
|
427
|
+
if self._settings.is_local_api():
|
|
428
|
+
note = " (currently forced unlimited: local-api mode)"
|
|
429
|
+
elif current <= 0:
|
|
430
|
+
note = " (0 = unlimited)"
|
|
431
|
+
else:
|
|
432
|
+
note = ""
|
|
433
|
+
return f"{config_key} is currently {current}{note}. Usage: /{command_name} <n> (0 = unlimited)"
|
|
434
|
+
|
|
435
|
+
def apply(raw: str) -> str:
|
|
436
|
+
try:
|
|
437
|
+
value = int(raw)
|
|
438
|
+
except ValueError:
|
|
439
|
+
raise _CommandError(f"'{raw}' isn't a valid number.") from None
|
|
440
|
+
if value < 0:
|
|
441
|
+
raise _CommandError(f"{config_key} must be 0 or greater (0 means unlimited).")
|
|
442
|
+
update_guardrails_limits(**{config_key: value})
|
|
443
|
+
if self._settings.is_local_api():
|
|
444
|
+
note = " This session is in local-api mode, so it stays unlimited until that changes."
|
|
445
|
+
else:
|
|
446
|
+
setattr(guardrails, attr_name, value)
|
|
447
|
+
note = f" Takes effect on the next {window}."
|
|
448
|
+
return f"{config_key} set to {value}.{note}"
|
|
449
|
+
|
|
450
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
451
|
+
|
|
452
|
+
async def handle_max_tool_calls_per_turn_command(self, chat_id: int, arg: str | None) -> None:
|
|
453
|
+
await self._handle_guardrail_rate_limit_command(
|
|
454
|
+
chat_id,
|
|
455
|
+
arg,
|
|
456
|
+
attr_name="max_tool_calls_per_turn",
|
|
457
|
+
config_key="max_tool_calls_per_turn",
|
|
458
|
+
command_name="max_tool_calls_per_turn",
|
|
459
|
+
window="turn",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def handle_max_tool_calls_per_minute_command(self, chat_id: int, arg: str | None) -> None:
|
|
463
|
+
await self._handle_guardrail_rate_limit_command(
|
|
464
|
+
chat_id,
|
|
465
|
+
arg,
|
|
466
|
+
attr_name="max_tool_calls_per_minute",
|
|
467
|
+
config_key="max_tool_calls_per_minute",
|
|
468
|
+
command_name="max_tool_calls_per_minute",
|
|
469
|
+
window="tool call",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
async def handle_prune_tool_results_command(self, chat_id: int, arg: str | None) -> None:
|
|
473
|
+
def view() -> str:
|
|
474
|
+
state = "enabled" if self._settings.prune_tool_results_enabled else "disabled"
|
|
475
|
+
return (
|
|
476
|
+
f"prune_tool_results is {state}, keeping the most recent "
|
|
477
|
+
f"{self._settings.prune_tool_results_keep_recent_turns} turn(s) verbatim. "
|
|
478
|
+
"Usage: /prune_tool_results off|on|<n>"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def apply(raw: str) -> str:
|
|
482
|
+
if raw == "off":
|
|
483
|
+
self._settings.prune_tool_results_enabled = False
|
|
484
|
+
update_config_file(prune_tool_results_enabled=False)
|
|
485
|
+
return "prune_tool_results disabled."
|
|
486
|
+
if raw == "on":
|
|
487
|
+
self._settings.prune_tool_results_enabled = True
|
|
488
|
+
update_config_file(prune_tool_results_enabled=True)
|
|
489
|
+
return "prune_tool_results enabled."
|
|
490
|
+
try:
|
|
491
|
+
keep_recent_turns = int(raw)
|
|
492
|
+
except ValueError:
|
|
493
|
+
raise _CommandError(f"'{raw}' isn't 'off', 'on', or a valid number.") from None
|
|
494
|
+
if keep_recent_turns <= 0:
|
|
495
|
+
raise _CommandError(
|
|
496
|
+
"keep_recent_turns must be greater than 0 (use /prune_tool_results off "
|
|
497
|
+
"to disable pruning entirely)."
|
|
498
|
+
)
|
|
499
|
+
self._settings.prune_tool_results_enabled = True
|
|
500
|
+
self._settings.prune_tool_results_keep_recent_turns = keep_recent_turns
|
|
501
|
+
update_config_file(
|
|
502
|
+
prune_tool_results_enabled=True,
|
|
503
|
+
prune_tool_results_keep_recent_turns=keep_recent_turns,
|
|
504
|
+
)
|
|
505
|
+
return (
|
|
506
|
+
f"prune_tool_results_keep_recent_turns set to {keep_recent_turns}. Takes "
|
|
507
|
+
"effect on the next turn."
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
511
|
+
|
|
512
|
+
async def handle_max_response_tokens_command(self, chat_id: int, arg: str | None) -> None:
|
|
513
|
+
def view() -> str:
|
|
514
|
+
state = "enabled" if self._settings.max_response_tokens_enabled else "disabled"
|
|
515
|
+
current = compute_max_response_tokens(
|
|
516
|
+
self._session,
|
|
517
|
+
limit_table=self._context_limit_table,
|
|
518
|
+
safety_margin=self._settings.max_response_tokens_safety_margin,
|
|
519
|
+
)
|
|
520
|
+
current_text = (
|
|
521
|
+
f"{current:,} tokens" if current is not None else "no cap (not yet computable)"
|
|
522
|
+
)
|
|
523
|
+
return (
|
|
524
|
+
f"max_response_tokens is {state}, safety margin "
|
|
525
|
+
f"{self._settings.max_response_tokens_safety_margin:,} tokens. Would "
|
|
526
|
+
f"currently send max_tokens={current_text}. Usage: /max_response_tokens "
|
|
527
|
+
"off|on|<margin>"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def apply(raw: str) -> str:
|
|
531
|
+
if raw == "off":
|
|
532
|
+
self._settings.max_response_tokens_enabled = False
|
|
533
|
+
update_config_file(max_response_tokens_enabled=False)
|
|
534
|
+
return "max_response_tokens disabled."
|
|
535
|
+
if raw == "on":
|
|
536
|
+
self._settings.max_response_tokens_enabled = True
|
|
537
|
+
update_config_file(max_response_tokens_enabled=True)
|
|
538
|
+
return "max_response_tokens enabled."
|
|
539
|
+
try:
|
|
540
|
+
safety_margin = int(raw)
|
|
541
|
+
except ValueError:
|
|
542
|
+
raise _CommandError(f"'{raw}' isn't 'off', 'on', or a valid number.") from None
|
|
543
|
+
if safety_margin <= 0:
|
|
544
|
+
raise _CommandError(
|
|
545
|
+
"safety margin must be greater than 0 (use /max_response_tokens off to "
|
|
546
|
+
"disable the cap entirely)."
|
|
547
|
+
)
|
|
548
|
+
self._settings.max_response_tokens_enabled = True
|
|
549
|
+
self._settings.max_response_tokens_safety_margin = safety_margin
|
|
550
|
+
update_config_file(
|
|
551
|
+
max_response_tokens_enabled=True,
|
|
552
|
+
max_response_tokens_safety_margin=safety_margin,
|
|
553
|
+
)
|
|
554
|
+
return (
|
|
555
|
+
f"max_response_tokens_safety_margin set to {safety_margin:,}. Takes effect "
|
|
556
|
+
"on the next turn."
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
|
|
560
|
+
|
|
561
|
+
async def handle_rename_command(self, chat_id: int, arg: str | None) -> None:
|
|
562
|
+
if not self._is_authorized(chat_id):
|
|
563
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
564
|
+
return
|
|
565
|
+
if not arg:
|
|
566
|
+
await self._sender.send_message(
|
|
567
|
+
chat_id,
|
|
568
|
+
f"Current session title: '{self._session.derive_title()}'. Usage: /rename <name>",
|
|
569
|
+
)
|
|
570
|
+
return
|
|
571
|
+
self._session.title = arg
|
|
572
|
+
self._store.save(self._session)
|
|
573
|
+
await self._sender.send_message(chat_id, f"Session renamed to '{arg}'.")
|
|
574
|
+
|
|
575
|
+
async def handle_allowed_roots_command(self, chat_id: int, rest: str) -> None:
|
|
576
|
+
"""Mirrors ChatScreen._handle_allowed_roots_command - view or edit
|
|
577
|
+
the filesystem guardrail's allowed_roots list. Telegram-side name
|
|
578
|
+
is /allowed_roots, not /allowed-roots - Telegram bot commands can't
|
|
579
|
+
contain hyphens (see the scalar-setting commands' own note on this).
|
|
580
|
+
"add"/"remove" are plain arguments, not command names, so they keep
|
|
581
|
+
their TUI spelling."""
|
|
582
|
+
if not self._is_authorized(chat_id):
|
|
583
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
584
|
+
return
|
|
585
|
+
guardrails = self._permission_manager.guardrails
|
|
586
|
+
sub_command, _, arg = rest.partition(" ")
|
|
587
|
+
sub_command = sub_command.strip().lower()
|
|
588
|
+
arg = arg.strip()
|
|
589
|
+
|
|
590
|
+
if not sub_command:
|
|
591
|
+
roots = "\n".join(f"- {root}" for root in guardrails.fs_allowed_roots)
|
|
592
|
+
await self._sender.send_message(
|
|
593
|
+
chat_id,
|
|
594
|
+
f"Current allowed_roots:\n{roots}\n\nUsage: /allowed_roots add <path> | "
|
|
595
|
+
"/allowed_roots remove <path>",
|
|
596
|
+
)
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
if sub_command == "add":
|
|
600
|
+
if not arg:
|
|
601
|
+
await self._sender.send_message(chat_id, "Usage: /allowed_roots add <path>")
|
|
602
|
+
return
|
|
603
|
+
if arg in guardrails.fs_allowed_roots:
|
|
604
|
+
await self._sender.send_message(chat_id, f"'{arg}' is already in allowed_roots.")
|
|
605
|
+
return
|
|
606
|
+
new_roots = [*guardrails.fs_allowed_roots, arg]
|
|
607
|
+
update_guardrails_fs_allowed_roots(new_roots)
|
|
608
|
+
guardrails.fs_allowed_roots = new_roots
|
|
609
|
+
await self._sender.send_message(
|
|
610
|
+
chat_id, f"Added '{arg}' to allowed_roots. Takes effect immediately."
|
|
611
|
+
)
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
if sub_command == "remove":
|
|
615
|
+
if not arg:
|
|
616
|
+
await self._sender.send_message(chat_id, "Usage: /allowed_roots remove <path>")
|
|
617
|
+
return
|
|
618
|
+
if arg not in guardrails.fs_allowed_roots:
|
|
619
|
+
await self._sender.send_message(chat_id, f"'{arg}' isn't in allowed_roots.")
|
|
620
|
+
return
|
|
621
|
+
if len(guardrails.fs_allowed_roots) == 1:
|
|
622
|
+
await self._sender.send_message(
|
|
623
|
+
chat_id,
|
|
624
|
+
"Refusing to remove the last allowed_roots entry - the agent needs at "
|
|
625
|
+
"least one, or every filesystem tool call would be denied.",
|
|
626
|
+
)
|
|
627
|
+
return
|
|
628
|
+
new_roots = [root for root in guardrails.fs_allowed_roots if root != arg]
|
|
629
|
+
update_guardrails_fs_allowed_roots(new_roots)
|
|
630
|
+
guardrails.fs_allowed_roots = new_roots
|
|
631
|
+
await self._sender.send_message(
|
|
632
|
+
chat_id, f"Removed '{arg}' from allowed_roots. Takes effect immediately."
|
|
633
|
+
)
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
await self._sender.send_message(
|
|
637
|
+
chat_id,
|
|
638
|
+
f"Unknown /allowed_roots subcommand: '{sub_command}'. Use /allowed_roots, "
|
|
639
|
+
"/allowed_roots add <path>, or /allowed_roots remove <path>.",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
async def handle_memory_command(self, chat_id: int, rest: str) -> None:
|
|
643
|
+
"""Mirrors ChatScreen._handle_memory_command - view, trim, or clear
|
|
644
|
+
pcli's global, cross-session memory (memory/store.py)."""
|
|
645
|
+
if not self._is_authorized(chat_id):
|
|
646
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
647
|
+
return
|
|
648
|
+
sub_command, _, arg = rest.partition(" ")
|
|
649
|
+
sub_command = sub_command.strip().lower()
|
|
650
|
+
arg = arg.strip()
|
|
651
|
+
|
|
652
|
+
if not sub_command:
|
|
653
|
+
await self._sender.send_message(chat_id, render_memory_list(read_memory().entries))
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
if sub_command == "clear":
|
|
657
|
+
clear_memory()
|
|
658
|
+
await self._sender.send_message(chat_id, "Cleared all memory entries.")
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
if sub_command == "forget":
|
|
662
|
+
if not arg:
|
|
663
|
+
await self._sender.send_message(chat_id, "Usage: /memory forget <id>")
|
|
664
|
+
return
|
|
665
|
+
matches = [e for e in read_memory().entries if e.id.endswith(arg)]
|
|
666
|
+
if not matches:
|
|
667
|
+
await self._sender.send_message(chat_id, f"No memory entry found matching '{arg}'.")
|
|
668
|
+
return
|
|
669
|
+
if len(matches) > 1:
|
|
670
|
+
await self._sender.send_message(
|
|
671
|
+
chat_id, f"'{arg}' matches more than one entry - use a longer id."
|
|
672
|
+
)
|
|
673
|
+
return
|
|
674
|
+
remove_entry(matches[0].id)
|
|
675
|
+
await self._sender.send_message(chat_id, f"Forgot: {matches[0].content}")
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
await self._sender.send_message(
|
|
679
|
+
chat_id,
|
|
680
|
+
f"Unknown /memory subcommand: '{sub_command}'. Use /memory, /memory forget <id>, "
|
|
681
|
+
"or /memory clear.",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
async def handle_help_command(self, chat_id: int) -> None:
|
|
685
|
+
if not self._is_authorized(chat_id):
|
|
686
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
687
|
+
return
|
|
688
|
+
await self._sender.send_message(chat_id, _HELP_TEXT)
|
|
689
|
+
|
|
690
|
+
async def handle_toolbox_command(self, chat_id: int, rest: str) -> None:
|
|
691
|
+
"""Mirrors ChatScreen._handle_toolbox_command/_toolbox_discover/
|
|
692
|
+
_toolbox_list/_toolbox_remove - discover, list, or remove toolbox
|
|
693
|
+
tools (CLI programs/scripts wrapped as callable tools). Unlike
|
|
694
|
+
chat.py, self._runtime.toolbox_manager and self._runtime.
|
|
695
|
+
tool_registry are always present here (AgentRuntime builds both for
|
|
696
|
+
every caller - see agent/runtime.py), so there's no "toolbox isn't
|
|
697
|
+
available" guard to port."""
|
|
698
|
+
if not self._is_authorized(chat_id):
|
|
699
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
700
|
+
return
|
|
701
|
+
sub, _, arg = rest.partition(" ")
|
|
702
|
+
arg = arg.strip()
|
|
703
|
+
|
|
704
|
+
if sub == "discover" and arg:
|
|
705
|
+
name, _, path = arg.partition(" ")
|
|
706
|
+
await self._toolbox_discover(chat_id, name, path.strip() or None)
|
|
707
|
+
elif sub == "list":
|
|
708
|
+
await self._toolbox_list(chat_id)
|
|
709
|
+
elif sub == "remove" and arg:
|
|
710
|
+
await self._toolbox_remove(chat_id, arg)
|
|
711
|
+
else:
|
|
712
|
+
await self._sender.send_message(
|
|
713
|
+
chat_id,
|
|
714
|
+
"Usage: /toolbox discover <name> [path] | /toolbox list | "
|
|
715
|
+
"/toolbox remove <name>",
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
async def _toolbox_discover(self, chat_id: int, name: str, path: str | None) -> None:
|
|
719
|
+
await self._sender.send_message(chat_id, f"Discovering '{name}'...")
|
|
720
|
+
try:
|
|
721
|
+
summary = await self._runtime.toolbox_manager.discover(
|
|
722
|
+
name,
|
|
723
|
+
gateway_client=self._runtime.client,
|
|
724
|
+
model=self._settings.default_model or None,
|
|
725
|
+
path=path,
|
|
726
|
+
)
|
|
727
|
+
except (ToolboxDiscoveryError, GatewayError, SandboxSecurityError) as exc:
|
|
728
|
+
await self._sender.send_message(chat_id, f"Discovery failed: {exc}")
|
|
729
|
+
return
|
|
730
|
+
await self._sender.send_message(chat_id, summary)
|
|
731
|
+
|
|
732
|
+
loaded = await self._runtime.toolbox_manager.load_all()
|
|
733
|
+
self._runtime.tool_registry.merge(loaded)
|
|
734
|
+
|
|
735
|
+
async def _toolbox_list(self, chat_id: int) -> None:
|
|
736
|
+
entries = self._runtime.toolbox_manager.list_discovered()
|
|
737
|
+
if not entries:
|
|
738
|
+
await self._sender.send_message(chat_id, "No software discovered yet. Try /toolbox discover <name>.")
|
|
739
|
+
return
|
|
740
|
+
lines = [
|
|
741
|
+
f"- {name} [{entry['source']}] {entry.get('version', '?')} - "
|
|
742
|
+
f"{entry.get('tool_count', 0)} tool(s)"
|
|
743
|
+
for name, entry in entries.items()
|
|
744
|
+
]
|
|
745
|
+
await self._sender.send_message(chat_id, "\n".join(lines))
|
|
746
|
+
|
|
747
|
+
async def _toolbox_remove(self, chat_id: int, name: str) -> None:
|
|
748
|
+
self._runtime.toolbox_manager.remove(name)
|
|
749
|
+
await self._sender.send_message(chat_id, f"Removed '{name}' from the toolbox.")
|
|
750
|
+
|
|
751
|
+
def _effective_compaction_model(self) -> str | None:
|
|
752
|
+
"""Mirrors ChatScreen._effective_compaction_model - settings.
|
|
753
|
+
compaction_model if configured (a deliberately cheaper/smaller
|
|
754
|
+
model for compaction/memory-extraction's mechanical, lower-stakes
|
|
755
|
+
calls), falling back to default_model/session.model otherwise."""
|
|
756
|
+
return (
|
|
757
|
+
self._settings.compaction_model
|
|
758
|
+
or self._settings.default_model
|
|
759
|
+
or self._session.model
|
|
760
|
+
or None
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
async def handle_compact_command(self, chat_id: int) -> None:
|
|
764
|
+
"""Mirrors ChatScreen._run_compaction's "manual" path - unlike
|
|
765
|
+
run_headless_task (which, deliberately narrower than ChatScreen's
|
|
766
|
+
own turn loop, never auto-compacts - see headless.py's module
|
|
767
|
+
docstring), the Telegram daemon has no other way to free up
|
|
768
|
+
context on a long-running conversation, so this is a real need
|
|
769
|
+
here, not just parity for its own sake."""
|
|
770
|
+
if not self._is_authorized(chat_id):
|
|
771
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
772
|
+
return
|
|
773
|
+
if self._turn_in_progress:
|
|
774
|
+
await self._sender.send_message(
|
|
775
|
+
chat_id, "Still working on the current turn - try /compact again once it's done."
|
|
776
|
+
)
|
|
777
|
+
return
|
|
778
|
+
|
|
779
|
+
artifact_store = SessionArtifactStore(self._store, self._session.id)
|
|
780
|
+
cost_tracker = CostTracker(self._session)
|
|
781
|
+
configured_keep_recent_turns = self._settings.auto_compact_keep_recent_turns
|
|
782
|
+
tightened_to: int | None = None
|
|
783
|
+
|
|
784
|
+
try:
|
|
785
|
+
result = await maybe_compact(
|
|
786
|
+
self._session,
|
|
787
|
+
gateway_client=self._runtime.client,
|
|
788
|
+
model=self._effective_compaction_model(),
|
|
789
|
+
artifact_store=artifact_store,
|
|
790
|
+
keep_recent_turns=configured_keep_recent_turns,
|
|
791
|
+
)
|
|
792
|
+
if result is None:
|
|
793
|
+
usage = current_context_usage(self._session, limit_table=self._context_limit_table)
|
|
794
|
+
if usage.fraction >= self._settings.auto_compact_threshold:
|
|
795
|
+
for smaller in range(configured_keep_recent_turns - 1, -1, -1):
|
|
796
|
+
result = await maybe_compact(
|
|
797
|
+
self._session,
|
|
798
|
+
gateway_client=self._runtime.client,
|
|
799
|
+
model=self._effective_compaction_model(),
|
|
800
|
+
artifact_store=artifact_store,
|
|
801
|
+
keep_recent_turns=smaller,
|
|
802
|
+
)
|
|
803
|
+
if result is not None:
|
|
804
|
+
tightened_to = smaller
|
|
805
|
+
break
|
|
806
|
+
except GatewayError as exc:
|
|
807
|
+
logger.exception("Gateway error during compaction: %s", exc.message)
|
|
808
|
+
await self._sender.send_message(chat_id, f"Compaction failed: {exc.message}")
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
if result is None:
|
|
812
|
+
await self._sender.send_message(chat_id, "Nothing to compact yet.")
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
self._session.tool_invocations.append(
|
|
816
|
+
ToolInvocation(
|
|
817
|
+
tool_name="_compaction",
|
|
818
|
+
arguments={},
|
|
819
|
+
status="ok",
|
|
820
|
+
result_summary=f"Compacted {result.messages_compacted} message(s).",
|
|
821
|
+
full_result_ref=SessionArtifactStore.blob_name_for(result.artifact_id),
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
cost_tracker.record_turn(
|
|
825
|
+
self._effective_compaction_model() or "", result.usage, source="compaction"
|
|
826
|
+
)
|
|
827
|
+
tightened_note = (
|
|
828
|
+
f" (kept only the last {tightened_to} recent turn(s) verbatim instead of the "
|
|
829
|
+
f"usual {configured_keep_recent_turns} - context was still full at that setting)"
|
|
830
|
+
if tightened_to is not None
|
|
831
|
+
else ""
|
|
832
|
+
)
|
|
833
|
+
await self._sender.send_message(
|
|
834
|
+
chat_id,
|
|
835
|
+
f"Compacted {result.messages_compacted} earlier message(s) to reduce context "
|
|
836
|
+
f"usage (archived as artifact_id='{result.artifact_id}'){tightened_note}.",
|
|
837
|
+
)
|
|
838
|
+
self._store.save(self._session)
|
|
839
|
+
|
|
840
|
+
if self._settings.memory_enabled:
|
|
841
|
+
await self._extract_memory_from(result.artifact_id, artifact_store, cost_tracker)
|
|
842
|
+
|
|
843
|
+
async def _extract_memory_from(
|
|
844
|
+
self, artifact_id: str, artifact_store: SessionArtifactStore, cost_tracker: CostTracker
|
|
845
|
+
) -> None:
|
|
846
|
+
"""Mirrors ChatScreen._extract_memory_from - best-effort: a failure
|
|
847
|
+
here is logged and otherwise invisible, since it never touched the
|
|
848
|
+
turn/command that triggered compaction."""
|
|
849
|
+
transcript = artifact_store.get(artifact_id)
|
|
850
|
+
if not transcript:
|
|
851
|
+
return
|
|
852
|
+
model = self._effective_compaction_model()
|
|
853
|
+
try:
|
|
854
|
+
extraction_ctx = replace(
|
|
855
|
+
make_tool_context(
|
|
856
|
+
self._runtime,
|
|
857
|
+
self._settings,
|
|
858
|
+
self._cwd,
|
|
859
|
+
session=self._session,
|
|
860
|
+
permission_manager=self._permission_manager,
|
|
861
|
+
artifact_store=artifact_store,
|
|
862
|
+
),
|
|
863
|
+
model=model,
|
|
864
|
+
)
|
|
865
|
+
usages = await extract_memory(transcript, extraction_ctx)
|
|
866
|
+
except GatewayError as exc:
|
|
867
|
+
logger.exception("Gateway error during memory extraction: %s", exc.message)
|
|
868
|
+
return
|
|
869
|
+
if not usages:
|
|
870
|
+
return
|
|
871
|
+
for usage in usages:
|
|
872
|
+
cost_tracker.record_turn(model or "", usage, source="memory")
|
|
873
|
+
self._store.save(self._session)
|
|
874
|
+
|
|
875
|
+
async def handle_models_command(self, chat_id: int, arg: str | None) -> None:
|
|
876
|
+
"""Mirrors ChatScreen._handle_models_command: an argument sets the
|
|
877
|
+
model directly (_set_model, same as chat.py's own); no argument
|
|
878
|
+
presents an inline-button picker - Telegram's equivalent of
|
|
879
|
+
chat.py's interactive ModelListScreen, reusing the same
|
|
880
|
+
callback_data mechanism permission prompts use (see
|
|
881
|
+
_encode_model_callback/_decode_model_callback and handle_callback
|
|
882
|
+
below). Model names can exceed Telegram's 64-byte callback_data
|
|
883
|
+
limit, so buttons carry a short random token instead of the name
|
|
884
|
+
itself - self._model_choices maps each token back to its model
|
|
885
|
+
name, rebuilt fresh on every /models call (a button from an older
|
|
886
|
+
listing silently stops working once a newer one replaces it, the
|
|
887
|
+
same "stale press is a harmless no-op" precedent
|
|
888
|
+
PendingApprovals already establishes for permission prompts)."""
|
|
889
|
+
if not self._is_authorized(chat_id):
|
|
890
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
891
|
+
return
|
|
892
|
+
if arg:
|
|
893
|
+
self._set_model(arg)
|
|
894
|
+
await self._sender.send_message(chat_id, f"Model set to '{arg}'.")
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
models = await self._runtime.client.list_models()
|
|
899
|
+
except GatewayError as exc:
|
|
900
|
+
await self._sender.send_message(chat_id, f"Failed to list models: {exc.message}")
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
if not models:
|
|
904
|
+
await self._sender.send_message(chat_id, "Gateway returned no models.")
|
|
905
|
+
return
|
|
906
|
+
|
|
907
|
+
current = self._settings.default_model or self._session.model or None
|
|
908
|
+
shown, omitted = models[:_MAX_MODEL_BUTTONS], models[_MAX_MODEL_BUTTONS:]
|
|
909
|
+
self._model_choices = {secrets.token_hex(4): model for model in shown}
|
|
910
|
+
buttons = [
|
|
911
|
+
(f"{'* ' if model == current else ''}{model}", _encode_model_callback(token))
|
|
912
|
+
for token, model in self._model_choices.items()
|
|
913
|
+
]
|
|
914
|
+
text = "Choose a model:" if not omitted else (
|
|
915
|
+
f"Choose a model (showing the first {len(shown)} of {len(models)} - use "
|
|
916
|
+
"/models <name> directly for one not listed):"
|
|
917
|
+
)
|
|
918
|
+
await self._sender.send_message(chat_id, text, buttons=buttons)
|
|
919
|
+
|
|
920
|
+
def _set_model(self, model: str) -> None:
|
|
921
|
+
self._settings.default_model = model
|
|
922
|
+
self._session.model = model
|
|
923
|
+
update_config_file(default_model=model)
|
|
924
|
+
|
|
925
|
+
async def handle_sessions_command(self, chat_id: int, rest: str) -> None:
|
|
926
|
+
"""`/sessions` lists other sessions (`SessionStore.list_index` -
|
|
927
|
+
already sorted most-recently-updated first); `/sessions switch
|
|
928
|
+
<id>` loads one and makes it self._session - the same
|
|
929
|
+
single-attribute swap /new already does safely (an in-flight
|
|
930
|
+
turn, if any, holds its own reference to the old Session object
|
|
931
|
+
via run_headless_task's `session` parameter, so swapping
|
|
932
|
+
self._session mid-turn doesn't corrupt anything in flight).
|
|
933
|
+
Unlike `pcli --resume`, which requires the full id, `<id>` here
|
|
934
|
+
can be any suffix of it - same convenience /memory forget <id>
|
|
935
|
+
already gives, since typing a full id on a phone keyboard is
|
|
936
|
+
impractical. Not chat.py's full interactive SessionListScreen
|
|
937
|
+
browser (sort/search/delete) - a text list covers the real need
|
|
938
|
+
(resuming a previous conversation from the phone)."""
|
|
939
|
+
if not self._is_authorized(chat_id):
|
|
940
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
941
|
+
return
|
|
942
|
+
sub_command, _, arg = rest.partition(" ")
|
|
943
|
+
sub_command = sub_command.strip().lower()
|
|
944
|
+
arg = arg.strip()
|
|
945
|
+
|
|
946
|
+
if not sub_command:
|
|
947
|
+
entries = [e for e in self._store.list_index() if e.id != self._session.id][:20]
|
|
948
|
+
if not entries:
|
|
949
|
+
await self._sender.send_message(chat_id, "No other sessions yet.")
|
|
950
|
+
return
|
|
951
|
+
lines = [
|
|
952
|
+
f"- {entry.id[-4:]} {entry.title} ({entry.message_count} msg, "
|
|
953
|
+
f"${entry.total_cost_usd:.4f}, {entry.updated_at:%Y-%m-%d %H:%M})"
|
|
954
|
+
for entry in entries
|
|
955
|
+
]
|
|
956
|
+
await self._sender.send_message(
|
|
957
|
+
chat_id,
|
|
958
|
+
"Other sessions (most recent first):\n"
|
|
959
|
+
+ "\n".join(lines)
|
|
960
|
+
+ "\n\nUsage: /sessions switch <id> (the short id shown above, or more of it)",
|
|
961
|
+
)
|
|
962
|
+
return
|
|
963
|
+
|
|
964
|
+
if sub_command == "switch":
|
|
965
|
+
if not arg:
|
|
966
|
+
await self._sender.send_message(chat_id, "Usage: /sessions switch <id>")
|
|
967
|
+
return
|
|
968
|
+
matches = [e for e in self._store.list_index() if e.id.endswith(arg)]
|
|
969
|
+
if not matches:
|
|
970
|
+
await self._sender.send_message(chat_id, f"No session found matching '{arg}'.")
|
|
971
|
+
return
|
|
972
|
+
if len(matches) > 1:
|
|
973
|
+
await self._sender.send_message(
|
|
974
|
+
chat_id, f"'{arg}' matches more than one session - use a longer id."
|
|
975
|
+
)
|
|
976
|
+
return
|
|
977
|
+
self._session = self._store.load(matches[0].id)
|
|
978
|
+
await self._sender.send_message(
|
|
979
|
+
chat_id, f"Switched to session '{self._session.derive_title()}'."
|
|
980
|
+
)
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
await self._sender.send_message(
|
|
984
|
+
chat_id,
|
|
985
|
+
f"Unknown /sessions subcommand: '{sub_command}'. Use /sessions, or /sessions "
|
|
986
|
+
"switch <id>.",
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
async def handle_export_command(self, chat_id: int, arg: str | None) -> None:
|
|
990
|
+
"""Mirrors ChatScreen._export_current's path/export_session call,
|
|
991
|
+
but sends the result back as a Telegram document instead of just
|
|
992
|
+
reporting a local path. chat.py's version reporting a path is
|
|
993
|
+
fine for a user sitting at the same machine; a remote Telegram
|
|
994
|
+
user has no other access to the host's filesystem at all, so a
|
|
995
|
+
bare path would be useless - the whole point of Telegram here is
|
|
996
|
+
remote use."""
|
|
997
|
+
if not self._is_authorized(chat_id):
|
|
998
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
999
|
+
return
|
|
1000
|
+
from pcli.config.paths import data_dir
|
|
1001
|
+
from pcli.session.export import export_session
|
|
1002
|
+
|
|
1003
|
+
out_path = (
|
|
1004
|
+
Path(arg).expanduser()
|
|
1005
|
+
if arg
|
|
1006
|
+
else data_dir() / "exports" / f"{self._session.id}.pcli-session.json"
|
|
1007
|
+
)
|
|
1008
|
+
export_session(self._session, out_path, store=self._store)
|
|
1009
|
+
await self._sender.send_document(chat_id, out_path)
|
|
1010
|
+
|
|
1011
|
+
async def _set_plan_mode(self, chat_id: int, enabled: bool) -> None:
|
|
1012
|
+
"""Mirrors ChatScreen._set_plan_mode - self._plan_mode is read by
|
|
1013
|
+
_process_turn on the next message (there's no live AgentLoop here
|
|
1014
|
+
to push a registry swap into immediately, same simplification
|
|
1015
|
+
already noted for the scalar-setting commands)."""
|
|
1016
|
+
if not self._is_authorized(chat_id):
|
|
1017
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
1018
|
+
return
|
|
1019
|
+
if enabled == self._plan_mode:
|
|
1020
|
+
await self._sender.send_message(
|
|
1021
|
+
chat_id, f"Already in {'plan' if enabled else 'build'} mode."
|
|
1022
|
+
)
|
|
1023
|
+
return
|
|
1024
|
+
self._plan_mode = enabled
|
|
1025
|
+
if enabled:
|
|
1026
|
+
await self._sender.send_message(
|
|
1027
|
+
chat_id,
|
|
1028
|
+
"Plan mode enabled: only read-only/exploration tools are available. Use "
|
|
1029
|
+
"/build to exit and allow writes/edits/shell commands again.",
|
|
1030
|
+
)
|
|
1031
|
+
else:
|
|
1032
|
+
await self._sender.send_message(chat_id, "Build mode restored: all tools are available again.")
|
|
1033
|
+
|
|
1034
|
+
async def handle_plan_command(self, chat_id: int) -> None:
|
|
1035
|
+
await self._set_plan_mode(chat_id, True)
|
|
1036
|
+
|
|
1037
|
+
async def handle_build_command(self, chat_id: int) -> None:
|
|
1038
|
+
await self._set_plan_mode(chat_id, False)
|
|
1039
|
+
|
|
1040
|
+
async def handle_new_command(self, chat_id: int) -> None:
|
|
1041
|
+
if not self._is_authorized(chat_id):
|
|
1042
|
+
return
|
|
1043
|
+
self._session = new_headless_session(self._store, self._settings, self._cwd)
|
|
1044
|
+
await self._sender.send_message(chat_id, "Started a new session.")
|
|
1045
|
+
|
|
1046
|
+
async def handle_unsupported_command(self, chat_id: int, command: str) -> None:
|
|
1047
|
+
"""Telegram doesn't have every TUI slash command yet (e.g. /models,
|
|
1048
|
+
/sessions, /plan aren't implemented here - see daemon.py's own
|
|
1049
|
+
handle_*_command methods for the current set). Without this,
|
|
1050
|
+
bot.py's handler registration means anything not explicitly
|
|
1051
|
+
registered matches no handler at all and is silently dropped by
|
|
1052
|
+
python-telegram-bot itself, before TelegramDaemon ever sees it - a
|
|
1053
|
+
real reported confusion ("I sent /models and nothing happened").
|
|
1054
|
+
Same authorization gate as handle_text/handle_new_command."""
|
|
1055
|
+
if not self._is_authorized(chat_id):
|
|
1056
|
+
logger.warning("Ignored message from unauthorized chat id %s", chat_id)
|
|
1057
|
+
return
|
|
1058
|
+
await self._sender.send_message(
|
|
1059
|
+
chat_id,
|
|
1060
|
+
f"'{command}' isn't a command this Telegram bot supports yet. Anything else "
|
|
1061
|
+
"(no leading /) is sent to the agent as a normal message.",
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
async def handle_callback(self, data: str) -> None:
|
|
1065
|
+
"""No chat-id check here on purpose: a callback_data payload is
|
|
1066
|
+
meaningless unless it matches an id/token this exact process
|
|
1067
|
+
handed out (PendingApprovals.register for a "perm:" payload,
|
|
1068
|
+
self._model_choices for a "model:" one) - there's nothing for an
|
|
1069
|
+
unauthorized chat to forge here even if it somehow saw the
|
|
1070
|
+
button. Tries the permission-decode first since that's the
|
|
1071
|
+
hot path (every tool call needing approval goes through it);
|
|
1072
|
+
the model-decode is just as cheap to try when that one misses."""
|
|
1073
|
+
decoded = decode_callback_data(data)
|
|
1074
|
+
if decoded is not None:
|
|
1075
|
+
request_id, result = decoded
|
|
1076
|
+
self._pending_approvals.resolve(request_id, result)
|
|
1077
|
+
return
|
|
1078
|
+
|
|
1079
|
+
token = _decode_model_callback(data)
|
|
1080
|
+
if token is None:
|
|
1081
|
+
return
|
|
1082
|
+
model = self._model_choices.get(token)
|
|
1083
|
+
if model is None:
|
|
1084
|
+
return
|
|
1085
|
+
self._set_model(model)
|
|
1086
|
+
await self._sender.send_message(self._chat_id, f"Model set to '{model}'.")
|
|
1087
|
+
|
|
1088
|
+
async def run_forever(self) -> None:
|
|
1089
|
+
"""The daemon's single-consumer loop - runs until cancelled (see
|
|
1090
|
+
cli.py's shutdown handling). A single bad turn (a bug in a tool, an
|
|
1091
|
+
unexpected exception) is caught and reported back to the chat
|
|
1092
|
+
rather than killing the whole daemon - a long-running process
|
|
1093
|
+
shouldn't die because one message went badly."""
|
|
1094
|
+
while True:
|
|
1095
|
+
text = await self._queue.get()
|
|
1096
|
+
try:
|
|
1097
|
+
await self._process(text)
|
|
1098
|
+
except Exception:
|
|
1099
|
+
logger.exception("Unhandled error processing a Telegram message")
|
|
1100
|
+
try:
|
|
1101
|
+
await self._sender.send_message(
|
|
1102
|
+
self._chat_id,
|
|
1103
|
+
"Something went wrong handling that - check pcli's logs.",
|
|
1104
|
+
)
|
|
1105
|
+
except Exception:
|
|
1106
|
+
logger.exception("Also failed to report that error back to Telegram")
|
|
1107
|
+
|
|
1108
|
+
async def _process(self, text: str) -> None:
|
|
1109
|
+
# Guards /compact below the same way ChatScreen._turn_in_progress
|
|
1110
|
+
# guards its own /compact - maybe_compact mutates session.messages
|
|
1111
|
+
# directly, which would race a turn also reading/appending to that
|
|
1112
|
+
# same list if the two ran concurrently.
|
|
1113
|
+
self._turn_in_progress = True
|
|
1114
|
+
try:
|
|
1115
|
+
await self._process_turn(text)
|
|
1116
|
+
finally:
|
|
1117
|
+
self._turn_in_progress = False
|
|
1118
|
+
|
|
1119
|
+
async def _process_turn(self, text: str) -> None:
|
|
1120
|
+
# A turn can take a while (several tool calls) with nothing else
|
|
1121
|
+
# sent back until it finishes - without this, a message mid-turn
|
|
1122
|
+
# looks identical to one that was never received at all. One
|
|
1123
|
+
# upfront acknowledgment is the right-sized fix for a chat
|
|
1124
|
+
# interface.
|
|
1125
|
+
await self._sender.send_message(self._chat_id, "Working on it...")
|
|
1126
|
+
|
|
1127
|
+
# run_headless_task's on_progress is a plain sync callback (it has
|
|
1128
|
+
# to be - it's called from inside an async-for loop it doesn't
|
|
1129
|
+
# control, see headless.py), so it can't await the real Telegram
|
|
1130
|
+
# send itself. It just queues the line; a background task drains
|
|
1131
|
+
# the queue and does the actual sending, so each tool call and its
|
|
1132
|
+
# result reaches the chat as it happens instead of only after the
|
|
1133
|
+
# whole turn finishes.
|
|
1134
|
+
progress: asyncio.Queue[str | None] = asyncio.Queue()
|
|
1135
|
+
|
|
1136
|
+
def on_progress(line: str) -> None:
|
|
1137
|
+
if not line.startswith("> "): # the echoed task text itself - redundant, skip
|
|
1138
|
+
progress.put_nowait(line)
|
|
1139
|
+
|
|
1140
|
+
async def forward_progress() -> None:
|
|
1141
|
+
while True:
|
|
1142
|
+
line = await progress.get()
|
|
1143
|
+
if line is None:
|
|
1144
|
+
return
|
|
1145
|
+
await self._sender.send_message(self._chat_id, line)
|
|
1146
|
+
|
|
1147
|
+
forwarder = asyncio.create_task(forward_progress())
|
|
1148
|
+
|
|
1149
|
+
async def ask(tool_name: str, arguments: dict, risk_description: str):
|
|
1150
|
+
return await ask_via_telegram(
|
|
1151
|
+
self._sender,
|
|
1152
|
+
self._pending_approvals,
|
|
1153
|
+
self._chat_id,
|
|
1154
|
+
tool_name,
|
|
1155
|
+
arguments,
|
|
1156
|
+
risk_description,
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
tool_registry = (
|
|
1160
|
+
self._runtime.tool_registry.filtered(lambda t: t.plan_mode_safe)
|
|
1161
|
+
if self._plan_mode
|
|
1162
|
+
else None
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
try:
|
|
1166
|
+
result = await run_headless_task(
|
|
1167
|
+
text,
|
|
1168
|
+
session=self._session,
|
|
1169
|
+
runtime=self._runtime,
|
|
1170
|
+
settings=self._settings,
|
|
1171
|
+
permission_manager=self._permission_manager,
|
|
1172
|
+
cwd=self._cwd,
|
|
1173
|
+
store=self._store,
|
|
1174
|
+
on_progress=on_progress,
|
|
1175
|
+
ask=ask,
|
|
1176
|
+
plan_mode=self._plan_mode,
|
|
1177
|
+
tool_registry=tool_registry,
|
|
1178
|
+
)
|
|
1179
|
+
except GatewayError as exc:
|
|
1180
|
+
# Drained before sending the error, not after, so any tool
|
|
1181
|
+
# calls that did complete before the gateway failed still show
|
|
1182
|
+
# up in the chat ahead of the error message instead of behind it.
|
|
1183
|
+
await progress.put(None)
|
|
1184
|
+
await forwarder
|
|
1185
|
+
await self._sender.send_message(self._chat_id, f"Gateway error: {exc.message}")
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
await progress.put(None)
|
|
1189
|
+
await forwarder
|
|
1190
|
+
|
|
1191
|
+
await self._sender.send_message(self._chat_id, result.final_text or "(no reply)")
|
|
1192
|
+
if result.truncations_exhausted:
|
|
1193
|
+
await self._sender.send_message(
|
|
1194
|
+
self._chat_id,
|
|
1195
|
+
"Response kept getting cut off by the token limit even after retrying - it "
|
|
1196
|
+
"may be incomplete. Send another message to continue.",
|
|
1197
|
+
)
|