abstractcode 0.1.0__py3-none-any.whl → 0.2.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.
- abstractcode/__init__.py +6 -37
- abstractcode/cli.py +110 -0
- abstractcode/fullscreen_ui.py +656 -0
- abstractcode/input_handler.py +81 -0
- abstractcode/react_shell.py +1204 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/METADATA +51 -5
- abstractcode-0.2.0.dist-info/RECORD +11 -0
- abstractcode-0.1.0.dist-info/RECORD +0 -7
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/WHEEL +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit.formatted_text import HTML
|
|
11
|
+
|
|
12
|
+
from .input_handler import create_prompt_session, create_simple_session
|
|
13
|
+
from .fullscreen_ui import FullScreenUI
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _supports_color() -> bool:
|
|
17
|
+
if os.environ.get("NO_COLOR"):
|
|
18
|
+
return False
|
|
19
|
+
return bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _C:
|
|
23
|
+
RESET = "\033[0m"
|
|
24
|
+
DIM = "\033[2m"
|
|
25
|
+
BOLD = "\033[1m"
|
|
26
|
+
CYAN = "\033[36m"
|
|
27
|
+
GREEN = "\033[32m"
|
|
28
|
+
YELLOW = "\033[33m"
|
|
29
|
+
MAGENTA = "\033[35m"
|
|
30
|
+
RED = "\033[31m"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _style(text: str, *codes: str, enabled: bool) -> str:
|
|
34
|
+
if not enabled or not codes:
|
|
35
|
+
return text
|
|
36
|
+
return "".join(codes) + text + _C.RESET
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _xml_safe(text: str) -> str:
|
|
40
|
+
"""Escape text for safe inclusion in prompt_toolkit HTML.
|
|
41
|
+
|
|
42
|
+
Removes XML-invalid control characters and then escapes HTML entities.
|
|
43
|
+
"""
|
|
44
|
+
import html as html_lib
|
|
45
|
+
import re
|
|
46
|
+
# Remove control characters except tab (\x09), newline (\x0a), carriage return (\x0d)
|
|
47
|
+
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', str(text))
|
|
48
|
+
return html_lib.escape(text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class _ToolSpec:
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
parameters: Dict[str, Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ReactShell:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
agent: str,
|
|
63
|
+
provider: str,
|
|
64
|
+
model: str,
|
|
65
|
+
state_file: Optional[str],
|
|
66
|
+
auto_approve: bool,
|
|
67
|
+
max_iterations: int,
|
|
68
|
+
max_tokens: Optional[int] = 32768,
|
|
69
|
+
color: bool,
|
|
70
|
+
):
|
|
71
|
+
self._agent_kind = str(agent or "react").strip().lower()
|
|
72
|
+
if self._agent_kind not in ("react", "codeact"):
|
|
73
|
+
raise ValueError("agent must be 'react' or 'codeact'")
|
|
74
|
+
self._provider = provider
|
|
75
|
+
self._model = model
|
|
76
|
+
self._state_file = state_file or None
|
|
77
|
+
self._auto_approve = auto_approve
|
|
78
|
+
self._max_iterations = int(max_iterations)
|
|
79
|
+
if self._max_iterations < 1:
|
|
80
|
+
raise ValueError("max_iterations must be >= 1")
|
|
81
|
+
self._max_tokens = max_tokens
|
|
82
|
+
# Enable ANSI colors - fullscreen_ui uses ANSI class to parse escape codes
|
|
83
|
+
self._color = bool(color and _supports_color())
|
|
84
|
+
|
|
85
|
+
# Lazy imports so `abstractcode --help` works even if deps aren't installed.
|
|
86
|
+
try:
|
|
87
|
+
from abstractagent.agents.codeact import CodeActAgent
|
|
88
|
+
from abstractagent.agents.react import ReactAgent
|
|
89
|
+
from abstractagent.tools import execute_python, self_improve
|
|
90
|
+
from abstractcore.tools import ToolDefinition
|
|
91
|
+
from abstractcore.tools.common_tools import (
|
|
92
|
+
list_files,
|
|
93
|
+
search_files,
|
|
94
|
+
read_file,
|
|
95
|
+
write_file,
|
|
96
|
+
edit_file,
|
|
97
|
+
execute_command,
|
|
98
|
+
web_search,
|
|
99
|
+
fetch_url,
|
|
100
|
+
)
|
|
101
|
+
from abstractruntime import InMemoryLedgerStore, InMemoryRunStore, JsonFileRunStore, JsonlLedgerStore
|
|
102
|
+
from abstractruntime.core.models import RunStatus, WaitReason
|
|
103
|
+
from abstractruntime.storage.snapshots import Snapshot, JsonSnapshotStore, InMemorySnapshotStore
|
|
104
|
+
from abstractruntime.integrations.abstractcore import (
|
|
105
|
+
LocalAbstractCoreLLMClient,
|
|
106
|
+
MappingToolExecutor,
|
|
107
|
+
PassthroughToolExecutor,
|
|
108
|
+
create_local_runtime,
|
|
109
|
+
)
|
|
110
|
+
except Exception as e: # pragma: no cover
|
|
111
|
+
raise SystemExit(
|
|
112
|
+
"AbstractCode requires AbstractAgent/AbstractRuntime/AbstractCore to be importable.\n"
|
|
113
|
+
"In this monorepo, run with:\n"
|
|
114
|
+
" PYTHONPATH=abstractcode:abstractagent/src:abstractruntime/src:abstractcore python -m abstractcode.cli\n"
|
|
115
|
+
f"\nImport error: {e}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self._RunStatus = RunStatus
|
|
119
|
+
self._WaitReason = WaitReason
|
|
120
|
+
self._Snapshot = Snapshot
|
|
121
|
+
self._JsonSnapshotStore = JsonSnapshotStore
|
|
122
|
+
self._InMemorySnapshotStore = InMemorySnapshotStore
|
|
123
|
+
|
|
124
|
+
# Default tools for AbstractCode (curated subset for coding tasks)
|
|
125
|
+
DEFAULT_TOOLS = [
|
|
126
|
+
list_files,
|
|
127
|
+
search_files,
|
|
128
|
+
read_file,
|
|
129
|
+
write_file,
|
|
130
|
+
edit_file,
|
|
131
|
+
execute_command,
|
|
132
|
+
web_search,
|
|
133
|
+
fetch_url,
|
|
134
|
+
self_improve,
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
if self._agent_kind == "react":
|
|
138
|
+
self._tools = list(DEFAULT_TOOLS)
|
|
139
|
+
agent_cls = ReactAgent
|
|
140
|
+
else:
|
|
141
|
+
self._tools = [execute_python]
|
|
142
|
+
agent_cls = CodeActAgent
|
|
143
|
+
|
|
144
|
+
self._tool_specs: Dict[str, _ToolSpec] = {}
|
|
145
|
+
for t in self._tools:
|
|
146
|
+
tool_def = getattr(t, "_tool_definition", None) or ToolDefinition.from_function(t)
|
|
147
|
+
self._tool_specs[tool_def.name] = _ToolSpec(
|
|
148
|
+
name=tool_def.name,
|
|
149
|
+
description=tool_def.description,
|
|
150
|
+
parameters=dict(tool_def.parameters or {}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
store_dir: Optional[Path] = None
|
|
154
|
+
# Stores: file-backed only when state_file is provided.
|
|
155
|
+
if self._state_file:
|
|
156
|
+
base = Path(self._state_file).expanduser().resolve()
|
|
157
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
store_dir = base.with_name(base.stem + ".d")
|
|
159
|
+
run_store = JsonFileRunStore(store_dir)
|
|
160
|
+
ledger_store = JsonlLedgerStore(store_dir)
|
|
161
|
+
self._snapshot_store = JsonSnapshotStore(store_dir / "snapshots")
|
|
162
|
+
else:
|
|
163
|
+
run_store = InMemoryRunStore()
|
|
164
|
+
ledger_store = InMemoryLedgerStore()
|
|
165
|
+
self._snapshot_store = InMemorySnapshotStore()
|
|
166
|
+
|
|
167
|
+
self._store_dir = store_dir
|
|
168
|
+
|
|
169
|
+
# Load saved config BEFORE creating agent (so agent gets correct values)
|
|
170
|
+
self._config_file: Optional[Path] = None
|
|
171
|
+
if self._state_file:
|
|
172
|
+
self._config_file = Path(self._state_file).with_suffix(".config.json")
|
|
173
|
+
self._load_config()
|
|
174
|
+
|
|
175
|
+
# Tool execution: passthrough by default so we can gate by approval in the CLI.
|
|
176
|
+
tool_executor = PassthroughToolExecutor(mode="approval_required")
|
|
177
|
+
self._tool_runner = MappingToolExecutor.from_tools(self._tools)
|
|
178
|
+
|
|
179
|
+
# Create LLM client for capability queries (used by /max-tokens -1)
|
|
180
|
+
self._llm_client = LocalAbstractCoreLLMClient(provider=self._provider, model=self._model)
|
|
181
|
+
|
|
182
|
+
self._runtime = create_local_runtime(
|
|
183
|
+
provider=self._provider,
|
|
184
|
+
model=self._model,
|
|
185
|
+
run_store=run_store,
|
|
186
|
+
ledger_store=ledger_store,
|
|
187
|
+
tool_executor=tool_executor,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self._agent = agent_cls(
|
|
191
|
+
runtime=self._runtime,
|
|
192
|
+
tools=self._tools,
|
|
193
|
+
on_step=self._on_step,
|
|
194
|
+
max_iterations=self._max_iterations,
|
|
195
|
+
max_tokens=self._max_tokens,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Session-level tool approval (persists across all requests)
|
|
199
|
+
self._approve_all_session = False
|
|
200
|
+
|
|
201
|
+
# Output buffer for full-screen mode
|
|
202
|
+
self._output_lines: List[str] = []
|
|
203
|
+
|
|
204
|
+
# Initialize full-screen UI with scrollable history
|
|
205
|
+
self._ui = FullScreenUI(
|
|
206
|
+
get_status_text=self._get_status_text,
|
|
207
|
+
on_input=self._handle_input,
|
|
208
|
+
color=self._color,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Keep simple session for tool approvals (runs within full-screen)
|
|
212
|
+
self._simple_session = create_simple_session(color=self._color)
|
|
213
|
+
|
|
214
|
+
# Pending input for the run loop
|
|
215
|
+
self._pending_input: Optional[str] = None
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------
|
|
218
|
+
# UI helpers
|
|
219
|
+
# ---------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _safe_get_state(self):
|
|
222
|
+
"""Safely get agent state, returning None if unavailable.
|
|
223
|
+
|
|
224
|
+
This handles the race condition where the render thread calls get_state()
|
|
225
|
+
while the worker thread has completed/cleaned up a run. The runtime raises
|
|
226
|
+
KeyError for unknown run_ids, which would crash the render loop.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
return self._agent.get_state()
|
|
230
|
+
except (KeyError, Exception):
|
|
231
|
+
# Run doesn't exist (completed/cleaned up) or other error
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def _get_status_text(self) -> str:
|
|
235
|
+
"""Generate status text for the status bar."""
|
|
236
|
+
# Get current context usage (safe for render thread)
|
|
237
|
+
state = self._safe_get_state()
|
|
238
|
+
if state:
|
|
239
|
+
messages = self._messages_from_state(state)
|
|
240
|
+
tokens_used = sum(len(str(m.get("content", ""))) // 4 for m in messages)
|
|
241
|
+
else:
|
|
242
|
+
messages = list(self._agent.session_messages or [])
|
|
243
|
+
tokens_used = sum(len(str(m.get("content", ""))) // 4 for m in messages)
|
|
244
|
+
|
|
245
|
+
max_tokens = self._max_tokens or 32768
|
|
246
|
+
pct = (tokens_used / max_tokens) * 100 if max_tokens > 0 else 0
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
f"{self._provider} | {self._model} | "
|
|
250
|
+
f"Context: {tokens_used:,}/{max_tokens:,} ({pct:.0f}%)"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _print(self, text: str = "") -> None:
|
|
254
|
+
"""Append text to the UI output area."""
|
|
255
|
+
self._output_lines.append(text)
|
|
256
|
+
self._ui.append_output(text)
|
|
257
|
+
|
|
258
|
+
def _handle_input(self, text: str) -> None:
|
|
259
|
+
"""Handle user input from the UI (called from worker thread)."""
|
|
260
|
+
text = text.strip()
|
|
261
|
+
if not text:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# Echo user input
|
|
265
|
+
self._print(f"\n> {text}")
|
|
266
|
+
|
|
267
|
+
cmd = text.strip()
|
|
268
|
+
|
|
269
|
+
if cmd.startswith("/"):
|
|
270
|
+
should_exit = self._dispatch_command(cmd[1:].strip())
|
|
271
|
+
if should_exit:
|
|
272
|
+
self._ui.stop()
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
# Reserved words check
|
|
276
|
+
lower = cmd.lower()
|
|
277
|
+
if lower in ("help", "tools", "status", "history", "resume", "quit", "exit", "q", "task", "clear", "reset", "new", "snapshot"):
|
|
278
|
+
self._print(_style("Commands must start with '/'.", _C.DIM, enabled=self._color))
|
|
279
|
+
self._print(_style(f"Try: /{lower}", _C.DIM, enabled=self._color))
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
# Otherwise treat as a task
|
|
283
|
+
self._start(cmd)
|
|
284
|
+
|
|
285
|
+
def _simple_prompt(self, message: str) -> str:
|
|
286
|
+
"""Single-line prompt for tool approvals (blocks worker thread).
|
|
287
|
+
|
|
288
|
+
This uses blocking_prompt which queues a response and waits for user input.
|
|
289
|
+
"""
|
|
290
|
+
result = self._ui.blocking_prompt(message)
|
|
291
|
+
if result:
|
|
292
|
+
self._print(f" → {result}")
|
|
293
|
+
return result.strip()
|
|
294
|
+
|
|
295
|
+
def _banner(self) -> None:
|
|
296
|
+
self._print(_style("AbstractCode (MVP)", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
297
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
298
|
+
self._print(f"Provider: {self._provider} Model: {self._model}")
|
|
299
|
+
if self._state_file:
|
|
300
|
+
store = str(self._store_dir) + "/" if self._store_dir else "(unknown)"
|
|
301
|
+
self._print(f"State: {self._state_file} (store: {store})")
|
|
302
|
+
else:
|
|
303
|
+
self._print("State: (in-memory; cannot resume after quitting)")
|
|
304
|
+
mode = "auto-approve" if self._auto_approve else "approval-gated"
|
|
305
|
+
self._print(f"Tools: {len(self._tools)} ({mode})")
|
|
306
|
+
self._print(_style("Type '/help' for commands.", _C.DIM, enabled=self._color))
|
|
307
|
+
|
|
308
|
+
def _on_step(self, step: str, data: Dict[str, Any]) -> None:
|
|
309
|
+
if step == "init":
|
|
310
|
+
task = (data.get("task") or "")[:80]
|
|
311
|
+
self._print(_style("\nStarting:", _C.CYAN, _C.BOLD, enabled=self._color) + f" {task}")
|
|
312
|
+
self._ui.set_spinner("Initializing...")
|
|
313
|
+
elif step == "reason":
|
|
314
|
+
it = data.get("iteration", "?")
|
|
315
|
+
max_it = data.get("max_iterations", "?")
|
|
316
|
+
self._print(_style(f"Thinking (step {it}/{max_it})...", _C.YELLOW, enabled=self._color))
|
|
317
|
+
self._ui.set_spinner(f"Thinking (step {it}/{max_it})...")
|
|
318
|
+
elif step == "act":
|
|
319
|
+
tool = data.get("tool", "unknown")
|
|
320
|
+
args = data.get("args") or {}
|
|
321
|
+
args_str = json.dumps(args, ensure_ascii=False)
|
|
322
|
+
if len(args_str) > 100:
|
|
323
|
+
args_str = args_str[:97] + "..."
|
|
324
|
+
self._print(_style("Tool:", _C.GREEN, enabled=self._color) + f" {tool}({args_str})")
|
|
325
|
+
self._ui.set_spinner(f"Running {tool}...")
|
|
326
|
+
elif step == "observe":
|
|
327
|
+
res = str(data.get("result", ""))[:120]
|
|
328
|
+
self._print(_style("Result:", _C.DIM, enabled=self._color) + f" {res}")
|
|
329
|
+
self._ui.set_spinner("Processing result...")
|
|
330
|
+
elif step == "ask_user":
|
|
331
|
+
self._ui.clear_spinner()
|
|
332
|
+
self._print(_style("Agent question:", _C.MAGENTA, _C.BOLD, enabled=self._color))
|
|
333
|
+
elif step == "done":
|
|
334
|
+
self._ui.clear_spinner()
|
|
335
|
+
self._print(_style("\nANSWER", _C.GREEN, _C.BOLD, enabled=self._color))
|
|
336
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
337
|
+
self._print(str(data.get("answer", "")))
|
|
338
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
339
|
+
elif step == "error" or step == "failed":
|
|
340
|
+
self._ui.clear_spinner()
|
|
341
|
+
elif step == "max_iterations":
|
|
342
|
+
self._ui.clear_spinner()
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------
|
|
345
|
+
# Commands
|
|
346
|
+
# ---------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def run(self) -> None:
|
|
349
|
+
# Build initial banner text
|
|
350
|
+
banner_lines = []
|
|
351
|
+
banner_lines.append(_style("AbstractCode (MVP)", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
352
|
+
banner_lines.append(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
353
|
+
banner_lines.append(f"Provider: {self._provider} Model: {self._model}")
|
|
354
|
+
if self._state_file:
|
|
355
|
+
store = str(self._store_dir) + "/" if self._store_dir else "(unknown)"
|
|
356
|
+
banner_lines.append(f"State: {self._state_file} (store: {store})")
|
|
357
|
+
else:
|
|
358
|
+
banner_lines.append("State: (in-memory; cannot resume after quitting)")
|
|
359
|
+
mode = "auto-approve" if self._auto_approve else "approval-gated"
|
|
360
|
+
banner_lines.append(f"Tools: {len(self._tools)} ({mode})")
|
|
361
|
+
banner_lines.append(_style("Type '/help' for commands.", _C.DIM, enabled=self._color))
|
|
362
|
+
banner_lines.append("")
|
|
363
|
+
|
|
364
|
+
# Add tools list to banner
|
|
365
|
+
banner_lines.append(_style("Available tools", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
366
|
+
banner_lines.append(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
367
|
+
for name, spec in sorted(self._tool_specs.items()):
|
|
368
|
+
params = ", ".join(sorted((spec.parameters or {}).keys()))
|
|
369
|
+
banner_lines.append(f"- {name}({params})")
|
|
370
|
+
banner_lines.append(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
371
|
+
|
|
372
|
+
if self._state_file:
|
|
373
|
+
self._try_load_state()
|
|
374
|
+
|
|
375
|
+
# Run the UI loop - this stays in full-screen mode continuously.
|
|
376
|
+
# All input is handled by _handle_input() via the worker thread.
|
|
377
|
+
self._ui.run_loop(banner="\n".join(banner_lines))
|
|
378
|
+
|
|
379
|
+
def _dispatch_command(self, raw: str) -> bool:
|
|
380
|
+
if not raw:
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
parts = raw.split(None, 1)
|
|
384
|
+
command = parts[0].lower()
|
|
385
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
386
|
+
|
|
387
|
+
if command in ("quit", "exit", "q"):
|
|
388
|
+
return True
|
|
389
|
+
if command in ("help", "h", "?"):
|
|
390
|
+
self._show_help()
|
|
391
|
+
return False
|
|
392
|
+
if command == "tools":
|
|
393
|
+
self._show_tools()
|
|
394
|
+
return False
|
|
395
|
+
if command == "status":
|
|
396
|
+
self._show_status()
|
|
397
|
+
return False
|
|
398
|
+
if command in ("auto-accept", "auto_accept"):
|
|
399
|
+
self._set_auto_accept(arg)
|
|
400
|
+
return False
|
|
401
|
+
if command == "resume":
|
|
402
|
+
self._resume()
|
|
403
|
+
return False
|
|
404
|
+
if command == "history":
|
|
405
|
+
limit = 12
|
|
406
|
+
if arg:
|
|
407
|
+
try:
|
|
408
|
+
limit = int(arg)
|
|
409
|
+
except ValueError:
|
|
410
|
+
self._print(_style("Usage: /history [N]", _C.DIM, enabled=self._color))
|
|
411
|
+
return False
|
|
412
|
+
self._show_history(limit=limit)
|
|
413
|
+
return False
|
|
414
|
+
if command == "task":
|
|
415
|
+
task = arg.strip()
|
|
416
|
+
if not task:
|
|
417
|
+
self._print(_style("Usage: /task <your task>", _C.DIM, enabled=self._color))
|
|
418
|
+
return False
|
|
419
|
+
self._start(task)
|
|
420
|
+
return False
|
|
421
|
+
if command in ("clear", "reset", "new"):
|
|
422
|
+
self._clear_memory()
|
|
423
|
+
return False
|
|
424
|
+
if command == "snapshot":
|
|
425
|
+
self._handle_snapshot(arg)
|
|
426
|
+
return False
|
|
427
|
+
if command == "max-tokens":
|
|
428
|
+
self._handle_max_tokens(arg)
|
|
429
|
+
return False
|
|
430
|
+
if command in ("max-messages", "max_messages"):
|
|
431
|
+
self._handle_max_messages(arg)
|
|
432
|
+
return False
|
|
433
|
+
if command == "memory":
|
|
434
|
+
self._handle_memory()
|
|
435
|
+
return False
|
|
436
|
+
if command == "compact":
|
|
437
|
+
self._handle_compact(arg)
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
self._print(_style(f"Unknown command: /{command}", _C.YELLOW, enabled=self._color))
|
|
441
|
+
self._print(_style("Type /help for commands.", _C.DIM, enabled=self._color))
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
def _set_auto_accept(self, raw: str) -> None:
|
|
445
|
+
value = raw.strip().lower()
|
|
446
|
+
if not value:
|
|
447
|
+
self._auto_approve = not self._auto_approve
|
|
448
|
+
elif value in ("on", "true", "1", "yes", "y"):
|
|
449
|
+
self._auto_approve = True
|
|
450
|
+
elif value in ("off", "false", "0", "no", "n"):
|
|
451
|
+
self._auto_approve = False
|
|
452
|
+
else:
|
|
453
|
+
self._print(_style("Usage: /auto-accept [on|off]", _C.DIM, enabled=self._color))
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
status = "ON (no approval prompts)" if self._auto_approve else "OFF (approval-gated)"
|
|
457
|
+
self._print(_style(f"Auto-accept is now {status}.", _C.DIM, enabled=self._color))
|
|
458
|
+
self._save_config()
|
|
459
|
+
|
|
460
|
+
def _handle_max_tokens(self, raw: str) -> None:
|
|
461
|
+
"""Show or set max tokens for context."""
|
|
462
|
+
value = raw.strip()
|
|
463
|
+
if not value:
|
|
464
|
+
# Show current
|
|
465
|
+
if self._max_tokens is None:
|
|
466
|
+
self._print("Max tokens: (auto)")
|
|
467
|
+
else:
|
|
468
|
+
self._print(f"Max tokens: {self._max_tokens:,}")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
tokens = int(value)
|
|
473
|
+
if tokens == -1:
|
|
474
|
+
# Auto-detect from model capabilities via abstractruntime's LLM client
|
|
475
|
+
try:
|
|
476
|
+
capabilities = self._llm_client.get_model_capabilities()
|
|
477
|
+
detected = capabilities.get("max_tokens", 32768)
|
|
478
|
+
self._max_tokens = detected
|
|
479
|
+
self._reconfigure_agent()
|
|
480
|
+
self._print(_style(f"Max tokens auto-detected: {detected:,} (from model capabilities)", _C.GREEN, enabled=self._color))
|
|
481
|
+
except Exception as e:
|
|
482
|
+
self._print(_style(f"Auto-detection failed: {e}. Using default 32768.", _C.YELLOW, enabled=self._color))
|
|
483
|
+
self._max_tokens = 32768
|
|
484
|
+
self._reconfigure_agent()
|
|
485
|
+
return
|
|
486
|
+
if tokens < 1024:
|
|
487
|
+
self._print(_style("Max tokens must be -1 (auto) or >= 1024", _C.YELLOW, enabled=self._color))
|
|
488
|
+
return
|
|
489
|
+
except ValueError:
|
|
490
|
+
self._print(_style("Usage: /max-tokens [number or -1 for auto]", _C.DIM, enabled=self._color))
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
self._max_tokens = tokens
|
|
494
|
+
# Immediately reconfigure the agent's logic with new max_tokens
|
|
495
|
+
self._reconfigure_agent()
|
|
496
|
+
self._print(_style(f"Max tokens set to {tokens:,} (immediate effect)", _C.GREEN, enabled=self._color))
|
|
497
|
+
|
|
498
|
+
def _reconfigure_agent(self) -> None:
|
|
499
|
+
"""Reconfigure the agent with updated settings (max_tokens, max_history_messages, etc.)."""
|
|
500
|
+
# Update the logic layer's max_tokens if the agent has a logic attribute
|
|
501
|
+
if hasattr(self._agent, "logic") and self._agent.logic is not None:
|
|
502
|
+
self._agent.logic._max_tokens = self._max_tokens
|
|
503
|
+
# Also update max_history_messages on the logic layer
|
|
504
|
+
if hasattr(self, "_max_history_messages"):
|
|
505
|
+
self._agent.logic._max_history_messages = self._max_history_messages
|
|
506
|
+
# Also update the agent's stored max_tokens
|
|
507
|
+
if hasattr(self._agent, "_max_tokens"):
|
|
508
|
+
self._agent._max_tokens = self._max_tokens
|
|
509
|
+
# Also update the agent's stored max_history_messages
|
|
510
|
+
if hasattr(self._agent, "_max_history_messages") and hasattr(self, "_max_history_messages"):
|
|
511
|
+
self._agent._max_history_messages = self._max_history_messages
|
|
512
|
+
# Save configuration to persist across restarts
|
|
513
|
+
self._save_config()
|
|
514
|
+
|
|
515
|
+
def _load_config(self) -> None:
|
|
516
|
+
"""Load configuration from file.
|
|
517
|
+
|
|
518
|
+
Called during __init__ before agent is created, so it just sets
|
|
519
|
+
instance variables. The agent will be created with these values.
|
|
520
|
+
"""
|
|
521
|
+
if not self._config_file or not self._config_file.exists():
|
|
522
|
+
return
|
|
523
|
+
try:
|
|
524
|
+
config = json.loads(self._config_file.read_text())
|
|
525
|
+
# Apply saved settings to instance variables
|
|
526
|
+
if "max_tokens" in config and config["max_tokens"] is not None:
|
|
527
|
+
self._max_tokens = config["max_tokens"]
|
|
528
|
+
if "max_history_messages" in config:
|
|
529
|
+
self._max_history_messages = config["max_history_messages"]
|
|
530
|
+
if "max_iterations" in config:
|
|
531
|
+
self._max_iterations = config["max_iterations"]
|
|
532
|
+
if "auto_approve" in config:
|
|
533
|
+
self._auto_approve = config["auto_approve"]
|
|
534
|
+
except Exception:
|
|
535
|
+
pass # Ignore corrupt config files
|
|
536
|
+
|
|
537
|
+
def _save_config(self) -> None:
|
|
538
|
+
"""Save configuration to file."""
|
|
539
|
+
if not self._config_file:
|
|
540
|
+
return
|
|
541
|
+
try:
|
|
542
|
+
config = {
|
|
543
|
+
"max_tokens": self._max_tokens,
|
|
544
|
+
"max_history_messages": getattr(self, "_max_history_messages", -1),
|
|
545
|
+
"max_iterations": self._max_iterations,
|
|
546
|
+
"auto_approve": self._auto_approve,
|
|
547
|
+
}
|
|
548
|
+
self._config_file.write_text(json.dumps(config, indent=2))
|
|
549
|
+
except Exception:
|
|
550
|
+
pass # Silently fail if we can't write
|
|
551
|
+
|
|
552
|
+
def _handle_max_messages(self, raw: str) -> None:
|
|
553
|
+
"""Show or set max history messages."""
|
|
554
|
+
value = raw.strip()
|
|
555
|
+
if not value:
|
|
556
|
+
# Show current
|
|
557
|
+
if hasattr(self._agent, "_max_history_messages"):
|
|
558
|
+
current = self._agent._max_history_messages
|
|
559
|
+
elif hasattr(self._agent, "logic") and self._agent.logic is not None:
|
|
560
|
+
current = self._agent.logic._max_history_messages
|
|
561
|
+
else:
|
|
562
|
+
current = -1
|
|
563
|
+
if current == -1:
|
|
564
|
+
self._print("Max history messages: -1 (unlimited, uses full history)")
|
|
565
|
+
else:
|
|
566
|
+
self._print(f"Max history messages: {current}")
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
num = int(value)
|
|
571
|
+
if num < -1 or num == 0:
|
|
572
|
+
self._print(_style("Must be -1 (unlimited) or >= 1", _C.YELLOW, enabled=self._color))
|
|
573
|
+
return
|
|
574
|
+
except ValueError:
|
|
575
|
+
self._print(_style("Usage: /max-messages [number]", _C.DIM, enabled=self._color))
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
self._max_history_messages = num
|
|
579
|
+
self._reconfigure_agent()
|
|
580
|
+
label = "unlimited" if num == -1 else str(num)
|
|
581
|
+
self._print(_style(f"Max history messages set to {label} (immediate effect)", _C.GREEN, enabled=self._color))
|
|
582
|
+
|
|
583
|
+
def _handle_memory(self) -> None:
|
|
584
|
+
"""Show memory/token usage breakdown."""
|
|
585
|
+
# Get current state and messages
|
|
586
|
+
state = self._safe_get_state()
|
|
587
|
+
if state is not None:
|
|
588
|
+
messages = self._messages_from_state(state)
|
|
589
|
+
else:
|
|
590
|
+
messages = list(self._agent.session_messages or [])
|
|
591
|
+
|
|
592
|
+
# Token estimation function (rough: 1 token ≈ 4 characters)
|
|
593
|
+
def estimate_tokens(text: str) -> int:
|
|
594
|
+
return len(text) // 4
|
|
595
|
+
|
|
596
|
+
# System prompt estimation (agent builds inline, estimate ~500 tokens)
|
|
597
|
+
system_tokens = 500
|
|
598
|
+
|
|
599
|
+
# History messages
|
|
600
|
+
history_text = ""
|
|
601
|
+
for m in messages:
|
|
602
|
+
history_text += f"{m.get('role', '')}: {m.get('content', '')}\n"
|
|
603
|
+
history_tokens = estimate_tokens(history_text)
|
|
604
|
+
|
|
605
|
+
# Tool definitions (schemas are verbose, multiply by ~10)
|
|
606
|
+
tool_names_text = json.dumps([name for name in self._tool_specs.keys()])
|
|
607
|
+
tool_tokens = estimate_tokens(tool_names_text) * 10
|
|
608
|
+
|
|
609
|
+
total_used = system_tokens + history_tokens + tool_tokens
|
|
610
|
+
max_tokens = self._max_tokens or 32768
|
|
611
|
+
|
|
612
|
+
self._print(_style("\nMemory Usage", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
613
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
614
|
+
self._print(f"Max tokens: {max_tokens:,}")
|
|
615
|
+
self._print(f"Estimated used: ~{total_used:,}")
|
|
616
|
+
self._print(f"Available: ~{max(0, max_tokens - total_used):,}")
|
|
617
|
+
self._print()
|
|
618
|
+
self._print("Breakdown (estimated):")
|
|
619
|
+
self._print(f" System/prompt: ~{system_tokens:,}")
|
|
620
|
+
self._print(f" Tool schemas: ~{tool_tokens:,}")
|
|
621
|
+
self._print(f" History ({len(messages)} msgs): ~{history_tokens:,}")
|
|
622
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
623
|
+
|
|
624
|
+
def _handle_compact(self, raw: str) -> None:
|
|
625
|
+
"""Handle /compact command for conversation compression.
|
|
626
|
+
|
|
627
|
+
Syntax: /compact [light|standard|heavy] [--preserve N] [focus topics...]
|
|
628
|
+
|
|
629
|
+
Examples:
|
|
630
|
+
/compact # Standard mode, 6 preserved, auto-focus
|
|
631
|
+
/compact light # Light compression
|
|
632
|
+
/compact heavy --preserve 4 # Heavy compression, keep 4 messages
|
|
633
|
+
/compact standard API design # Focus on "API design" topics
|
|
634
|
+
"""
|
|
635
|
+
import shlex
|
|
636
|
+
|
|
637
|
+
# Parse arguments
|
|
638
|
+
try:
|
|
639
|
+
parts = shlex.split(raw) if raw else []
|
|
640
|
+
except ValueError:
|
|
641
|
+
parts = raw.split()
|
|
642
|
+
|
|
643
|
+
# Defaults
|
|
644
|
+
compression_mode = "standard"
|
|
645
|
+
preserve_recent = 6
|
|
646
|
+
focus_topics = []
|
|
647
|
+
|
|
648
|
+
# Parse arguments
|
|
649
|
+
i = 0
|
|
650
|
+
while i < len(parts):
|
|
651
|
+
part = parts[i].lower()
|
|
652
|
+
if part == "--preserve":
|
|
653
|
+
if i + 1 < len(parts):
|
|
654
|
+
try:
|
|
655
|
+
preserve_recent = int(parts[i + 1])
|
|
656
|
+
if preserve_recent < 0:
|
|
657
|
+
self._print(_style("--preserve must be >= 0", _C.YELLOW, enabled=self._color))
|
|
658
|
+
return
|
|
659
|
+
i += 2
|
|
660
|
+
continue
|
|
661
|
+
except ValueError:
|
|
662
|
+
self._print(_style("--preserve requires a number", _C.YELLOW, enabled=self._color))
|
|
663
|
+
return
|
|
664
|
+
else:
|
|
665
|
+
self._print(_style("--preserve requires a number", _C.YELLOW, enabled=self._color))
|
|
666
|
+
return
|
|
667
|
+
elif part in ("light", "standard", "heavy"):
|
|
668
|
+
compression_mode = part
|
|
669
|
+
i += 1
|
|
670
|
+
else:
|
|
671
|
+
# Remaining args are focus topics
|
|
672
|
+
focus_topics.extend(parts[i:])
|
|
673
|
+
break
|
|
674
|
+
i += 1
|
|
675
|
+
|
|
676
|
+
# Build focus string
|
|
677
|
+
focus = " ".join(focus_topics) if focus_topics else None
|
|
678
|
+
|
|
679
|
+
# Get current messages
|
|
680
|
+
messages = list(self._agent.session_messages or [])
|
|
681
|
+
if not messages:
|
|
682
|
+
self._print(_style("No messages to compact.", _C.YELLOW, enabled=self._color))
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
# Check if we have enough messages to warrant compaction
|
|
686
|
+
non_system = [m for m in messages if m.get("role") != "system"]
|
|
687
|
+
if len(non_system) <= preserve_recent:
|
|
688
|
+
self._print(_style(
|
|
689
|
+
f"Only {len(non_system)} non-system messages - nothing to compact (preserving {preserve_recent}).",
|
|
690
|
+
_C.DIM, enabled=self._color
|
|
691
|
+
))
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
# Show what we're doing
|
|
695
|
+
self._print(_style("\nCompacting conversation...", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
696
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
697
|
+
self._print(f"Mode: {compression_mode}")
|
|
698
|
+
self._print(f"Preserve: {preserve_recent} recent messages")
|
|
699
|
+
self._print(f"Focus: {focus or '(auto-detect)'}")
|
|
700
|
+
self._print(f"Total messages: {len(messages)}")
|
|
701
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
702
|
+
|
|
703
|
+
self._ui.set_spinner("Compacting...")
|
|
704
|
+
|
|
705
|
+
try:
|
|
706
|
+
# Lazy import to avoid startup overhead
|
|
707
|
+
from abstractcore import create_llm
|
|
708
|
+
from abstractcore.processing import BasicSummarizer, CompressionMode
|
|
709
|
+
|
|
710
|
+
# Map string to enum
|
|
711
|
+
mode_map = {
|
|
712
|
+
"light": CompressionMode.LIGHT,
|
|
713
|
+
"standard": CompressionMode.STANDARD,
|
|
714
|
+
"heavy": CompressionMode.HEAVY,
|
|
715
|
+
}
|
|
716
|
+
mode_enum = mode_map[compression_mode]
|
|
717
|
+
|
|
718
|
+
# Create summarizer using the current provider
|
|
719
|
+
llm = create_llm(self._provider, model=self._model)
|
|
720
|
+
summarizer = BasicSummarizer(llm)
|
|
721
|
+
|
|
722
|
+
# Separate system messages from conversation
|
|
723
|
+
system_messages = [m for m in messages if m.get("role") == "system"]
|
|
724
|
+
conversation_messages = [m for m in messages if m.get("role") != "system"]
|
|
725
|
+
|
|
726
|
+
# Summarize
|
|
727
|
+
summary_result = summarizer.summarize_chat_history(
|
|
728
|
+
messages=conversation_messages,
|
|
729
|
+
preserve_recent=preserve_recent,
|
|
730
|
+
focus=focus,
|
|
731
|
+
compression_mode=mode_enum
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# Build new message list
|
|
735
|
+
new_messages = []
|
|
736
|
+
|
|
737
|
+
# Preserve system messages
|
|
738
|
+
new_messages.extend(system_messages)
|
|
739
|
+
|
|
740
|
+
# Add summary as system message
|
|
741
|
+
if len(conversation_messages) > preserve_recent:
|
|
742
|
+
new_messages.append({
|
|
743
|
+
"role": "system",
|
|
744
|
+
"content": f"[CONVERSATION HISTORY SUMMARY]: {summary_result.summary}"
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
# Add preserved recent messages
|
|
748
|
+
recent = conversation_messages[-preserve_recent:] if preserve_recent > 0 else []
|
|
749
|
+
new_messages.extend(recent)
|
|
750
|
+
|
|
751
|
+
# Replace session_messages in-place
|
|
752
|
+
self._agent.session_messages = new_messages
|
|
753
|
+
|
|
754
|
+
# Calculate stats
|
|
755
|
+
old_tokens = sum(len(str(m.get("content", ""))) // 4 for m in messages)
|
|
756
|
+
new_tokens = sum(len(str(m.get("content", ""))) // 4 for m in new_messages)
|
|
757
|
+
reduction = ((old_tokens - new_tokens) / old_tokens * 100) if old_tokens > 0 else 0
|
|
758
|
+
|
|
759
|
+
self._ui.clear_spinner()
|
|
760
|
+
|
|
761
|
+
self._print(_style("\n✅ Compaction complete!", _C.GREEN, _C.BOLD, enabled=self._color))
|
|
762
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
763
|
+
self._print(f"Messages: {len(messages)} → {len(new_messages)}")
|
|
764
|
+
self._print(f"Tokens: ~{old_tokens:,} → ~{new_tokens:,} ({reduction:.0f}% reduction)")
|
|
765
|
+
self._print(f"Confidence: {summary_result.confidence:.0%}")
|
|
766
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
767
|
+
|
|
768
|
+
# Show key points
|
|
769
|
+
if summary_result.key_points:
|
|
770
|
+
self._print(_style("\nKey points preserved:", _C.CYAN, enabled=self._color))
|
|
771
|
+
for point in summary_result.key_points[:5]:
|
|
772
|
+
truncated = point[:80] + "..." if len(point) > 80 else point
|
|
773
|
+
self._print(f" • {truncated}")
|
|
774
|
+
|
|
775
|
+
except ImportError as e:
|
|
776
|
+
self._ui.clear_spinner()
|
|
777
|
+
self._print(_style(f"Import error: {e}", _C.RED, enabled=self._color))
|
|
778
|
+
self._print(_style("Ensure abstractcore is properly installed.", _C.DIM, enabled=self._color))
|
|
779
|
+
except Exception as e:
|
|
780
|
+
self._ui.clear_spinner()
|
|
781
|
+
self._print(_style(f"Compaction failed: {e}", _C.RED, enabled=self._color))
|
|
782
|
+
|
|
783
|
+
def _show_help(self) -> None:
|
|
784
|
+
self._print(
|
|
785
|
+
"\nCommands:\n"
|
|
786
|
+
" /help Show this message\n"
|
|
787
|
+
" /tools List available tools\n"
|
|
788
|
+
" /status Show current run status\n"
|
|
789
|
+
" /auto-accept Toggle auto-accept for tools [saved]\n"
|
|
790
|
+
" /max-tokens [N] Show or set max tokens (-1 = auto) [saved]\n"
|
|
791
|
+
" /max-messages [N] Show or set max history messages (-1 = unlimited) [saved]\n"
|
|
792
|
+
" /memory Show current token usage breakdown\n"
|
|
793
|
+
" /compact [mode] Compress conversation context [light|standard|heavy]\n"
|
|
794
|
+
" /history [N] Show recent conversation history\n"
|
|
795
|
+
" /resume Resume the saved/attached run\n"
|
|
796
|
+
" /clear Clear memory and start fresh (aliases: /reset, /new)\n"
|
|
797
|
+
" /snapshot save <n> Save current state as named snapshot\n"
|
|
798
|
+
" /snapshot load <n> Load snapshot by name\n"
|
|
799
|
+
" /snapshot list List available snapshots\n"
|
|
800
|
+
" /quit Exit\n"
|
|
801
|
+
"\nTasks:\n"
|
|
802
|
+
" /task <text> Start a new task\n"
|
|
803
|
+
" <text> Start a new task (any line not starting with '/')\n"
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
def _show_tools(self) -> None:
|
|
807
|
+
self._print(_style("\nAvailable tools", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
808
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
809
|
+
for name, spec in sorted(self._tool_specs.items()):
|
|
810
|
+
params = ", ".join(sorted((spec.parameters or {}).keys()))
|
|
811
|
+
self._print(f"- {name}({params})")
|
|
812
|
+
self._print(_style(f" {spec.description}", _C.DIM, enabled=self._color))
|
|
813
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
814
|
+
|
|
815
|
+
def _show_status(self) -> None:
|
|
816
|
+
state = self._safe_get_state()
|
|
817
|
+
if state is None:
|
|
818
|
+
self._print("No active run.")
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
self._print(_style("\nRun status", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
822
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
823
|
+
self._print(f"Run ID: {state.run_id}")
|
|
824
|
+
self._print(f"Workflow: {state.workflow_id}")
|
|
825
|
+
self._print(f"Status: {state.status.value}")
|
|
826
|
+
self._print(f"Node: {state.current_node}")
|
|
827
|
+
if state.waiting:
|
|
828
|
+
self._print(f"Waiting: {state.waiting.reason.value}")
|
|
829
|
+
if state.waiting.prompt:
|
|
830
|
+
self._print(f"Prompt: {state.waiting.prompt}")
|
|
831
|
+
self._print(_style("─" * 40, _C.DIM, enabled=self._color))
|
|
832
|
+
|
|
833
|
+
def _messages_from_state(self, state: Any) -> List[Dict[str, Any]]:
|
|
834
|
+
context = state.vars.get("context") if hasattr(state, "vars") else None
|
|
835
|
+
if isinstance(context, dict) and isinstance(context.get("messages"), list):
|
|
836
|
+
return list(context["messages"])
|
|
837
|
+
if hasattr(state, "vars") and isinstance(state.vars.get("messages"), list):
|
|
838
|
+
return list(state.vars["messages"])
|
|
839
|
+
if getattr(state, "output", None) and isinstance(state.output.get("messages"), list):
|
|
840
|
+
return list(state.output["messages"])
|
|
841
|
+
return []
|
|
842
|
+
|
|
843
|
+
def _show_history(self, *, limit: int = 12) -> None:
|
|
844
|
+
state = self._safe_get_state()
|
|
845
|
+
if state is None:
|
|
846
|
+
messages = list(self._agent.session_messages or [])
|
|
847
|
+
else:
|
|
848
|
+
messages = self._messages_from_state(state)
|
|
849
|
+
if not messages:
|
|
850
|
+
self._print("No history yet.")
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
self._print(_style("\nHistory", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
854
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
855
|
+
for m in messages[-limit:]:
|
|
856
|
+
role = m.get("role", "unknown")
|
|
857
|
+
content = (m.get("content") or "").strip()
|
|
858
|
+
if len(content) > 240:
|
|
859
|
+
content = content[:237] + "..."
|
|
860
|
+
self._print(f"{role}: {content}")
|
|
861
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
862
|
+
|
|
863
|
+
def _clear_memory(self) -> None:
|
|
864
|
+
"""Clear all memory and reset to fresh state."""
|
|
865
|
+
# Clear session messages
|
|
866
|
+
self._agent.session_messages = []
|
|
867
|
+
|
|
868
|
+
# Clear run ID so next task starts fresh
|
|
869
|
+
self._agent._current_run_id = None
|
|
870
|
+
|
|
871
|
+
# Reset approval state (clear = full reset)
|
|
872
|
+
self._approve_all_session = False
|
|
873
|
+
|
|
874
|
+
self._print(_style("Memory cleared. Ready for a fresh start.", _C.GREEN, enabled=self._color))
|
|
875
|
+
|
|
876
|
+
def _handle_snapshot(self, arg: str) -> None:
|
|
877
|
+
"""Handle /snapshot save|load|list commands."""
|
|
878
|
+
parts = arg.split(None, 1)
|
|
879
|
+
if not parts:
|
|
880
|
+
self._print(_style("Usage: /snapshot save <name> | /snapshot load <name> | /snapshot list", _C.DIM, enabled=self._color))
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
subcommand = parts[0].lower()
|
|
884
|
+
name = parts[1].strip() if len(parts) > 1 else ""
|
|
885
|
+
|
|
886
|
+
if subcommand == "save":
|
|
887
|
+
self._snapshot_save(name)
|
|
888
|
+
elif subcommand == "load":
|
|
889
|
+
self._snapshot_load(name)
|
|
890
|
+
elif subcommand == "list":
|
|
891
|
+
self._snapshot_list()
|
|
892
|
+
else:
|
|
893
|
+
self._print(_style(f"Unknown snapshot command: {subcommand}", _C.YELLOW, enabled=self._color))
|
|
894
|
+
self._print(_style("Usage: /snapshot save <name> | /snapshot load <name> | /snapshot list", _C.DIM, enabled=self._color))
|
|
895
|
+
|
|
896
|
+
def _snapshot_save(self, name: str) -> None:
|
|
897
|
+
"""Save current state as a named snapshot."""
|
|
898
|
+
if not name:
|
|
899
|
+
self._print(_style("Usage: /snapshot save <name>", _C.DIM, enabled=self._color))
|
|
900
|
+
return
|
|
901
|
+
|
|
902
|
+
state = self._safe_get_state()
|
|
903
|
+
if state is None:
|
|
904
|
+
self._print(_style("No active run to snapshot.", _C.YELLOW, enabled=self._color))
|
|
905
|
+
return
|
|
906
|
+
|
|
907
|
+
snapshot = self._Snapshot.from_run(run=state, name=name)
|
|
908
|
+
self._snapshot_store.save(snapshot)
|
|
909
|
+
|
|
910
|
+
self._print(_style(f"Snapshot saved: {name}", _C.GREEN, enabled=self._color))
|
|
911
|
+
self._print(_style(f"ID: {snapshot.snapshot_id}", _C.DIM, enabled=self._color))
|
|
912
|
+
|
|
913
|
+
def _snapshot_load(self, name: str) -> None:
|
|
914
|
+
"""Load a snapshot by name."""
|
|
915
|
+
if not name:
|
|
916
|
+
self._print(_style("Usage: /snapshot load <name>", _C.DIM, enabled=self._color))
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
# Find snapshot by name
|
|
920
|
+
snapshots = self._snapshot_store.list(query=name)
|
|
921
|
+
if not snapshots:
|
|
922
|
+
self._print(_style(f"No snapshot found matching: {name}", _C.YELLOW, enabled=self._color))
|
|
923
|
+
return
|
|
924
|
+
|
|
925
|
+
# Prefer exact match, otherwise use first result
|
|
926
|
+
snapshot = next((s for s in snapshots if s.name.lower() == name.lower()), snapshots[0])
|
|
927
|
+
|
|
928
|
+
# Restore run state
|
|
929
|
+
run_state_dict = snapshot.run_state
|
|
930
|
+
if not run_state_dict:
|
|
931
|
+
self._print(_style("Snapshot has no run state.", _C.YELLOW, enabled=self._color))
|
|
932
|
+
return
|
|
933
|
+
|
|
934
|
+
# Restore messages to agent
|
|
935
|
+
messages = run_state_dict.get("vars", {}).get("context", {}).get("messages", [])
|
|
936
|
+
if messages:
|
|
937
|
+
self._agent.session_messages = list(messages)
|
|
938
|
+
|
|
939
|
+
self._print(_style(f"Snapshot loaded: {snapshot.name}", _C.GREEN, enabled=self._color))
|
|
940
|
+
self._print(_style(f"ID: {snapshot.snapshot_id}", _C.DIM, enabled=self._color))
|
|
941
|
+
if messages:
|
|
942
|
+
self._print(_style(f"Restored {len(messages)} messages.", _C.DIM, enabled=self._color))
|
|
943
|
+
|
|
944
|
+
def _snapshot_list(self) -> None:
|
|
945
|
+
"""List available snapshots."""
|
|
946
|
+
snapshots = self._snapshot_store.list(limit=20)
|
|
947
|
+
if not snapshots:
|
|
948
|
+
self._print("No snapshots saved.")
|
|
949
|
+
return
|
|
950
|
+
|
|
951
|
+
self._print(_style("\nSnapshots", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
952
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
953
|
+
for snap in snapshots:
|
|
954
|
+
created = snap.created_at[:19] if snap.created_at else "unknown"
|
|
955
|
+
self._print(f" {snap.name}")
|
|
956
|
+
self._print(_style(f" ID: {snap.snapshot_id[:8]}... Created: {created}", _C.DIM, enabled=self._color))
|
|
957
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
958
|
+
|
|
959
|
+
# ---------------------------------------------------------------------
|
|
960
|
+
# Execution
|
|
961
|
+
# ---------------------------------------------------------------------
|
|
962
|
+
|
|
963
|
+
def _start(self, task: str) -> None:
|
|
964
|
+
# Note: _approve_all_session is NOT reset here - it persists for the entire session
|
|
965
|
+
run_id = self._agent.start(task)
|
|
966
|
+
if self._state_file:
|
|
967
|
+
self._agent.save_state(self._state_file)
|
|
968
|
+
self._run_loop(run_id)
|
|
969
|
+
|
|
970
|
+
def _resume(self) -> None:
|
|
971
|
+
if self._agent.run_id is None and self._state_file:
|
|
972
|
+
self._try_load_state()
|
|
973
|
+
|
|
974
|
+
run_id = self._agent.run_id
|
|
975
|
+
if run_id is None:
|
|
976
|
+
self._print("No run to resume.")
|
|
977
|
+
return
|
|
978
|
+
|
|
979
|
+
self._run_loop(run_id)
|
|
980
|
+
|
|
981
|
+
def _try_load_state(self) -> None:
|
|
982
|
+
try:
|
|
983
|
+
state = self._agent.load_state(self._state_file) # type: ignore[arg-type]
|
|
984
|
+
except Exception as e:
|
|
985
|
+
self._print(_style("State load failed:", _C.YELLOW, enabled=self._color) + f" {e}")
|
|
986
|
+
return
|
|
987
|
+
if state is not None:
|
|
988
|
+
messages: Optional[List[Dict[str, Any]]] = None
|
|
989
|
+
loaded = self._messages_from_state(state)
|
|
990
|
+
if loaded:
|
|
991
|
+
messages = loaded
|
|
992
|
+
|
|
993
|
+
if messages is not None:
|
|
994
|
+
self._agent.session_messages = messages
|
|
995
|
+
|
|
996
|
+
if state.status == self._RunStatus.WAITING:
|
|
997
|
+
msg = "Loaded saved run. Type '/resume' to continue."
|
|
998
|
+
else:
|
|
999
|
+
msg = "Loaded history from last run."
|
|
1000
|
+
self._print(_style(msg, _C.DIM, enabled=self._color))
|
|
1001
|
+
|
|
1002
|
+
def _run_loop(self, run_id: str) -> None:
|
|
1003
|
+
while True:
|
|
1004
|
+
try:
|
|
1005
|
+
state = self._agent.step()
|
|
1006
|
+
except KeyboardInterrupt:
|
|
1007
|
+
self._ui.clear_spinner()
|
|
1008
|
+
state = self._safe_get_state()
|
|
1009
|
+
if state is not None:
|
|
1010
|
+
loaded = self._messages_from_state(state)
|
|
1011
|
+
if loaded:
|
|
1012
|
+
self._agent.session_messages = loaded
|
|
1013
|
+
self._print(_style("\nInterrupted. Run state preserved.", _C.YELLOW, enabled=self._color))
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
if state.status == self._RunStatus.COMPLETED:
|
|
1017
|
+
self._ui.clear_spinner()
|
|
1018
|
+
if state.output and isinstance(state.output.get("messages"), list):
|
|
1019
|
+
self._agent.session_messages = list(state.output["messages"])
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
if state.status == self._RunStatus.FAILED:
|
|
1023
|
+
self._ui.clear_spinner()
|
|
1024
|
+
self._print(_style("\nRun failed:", _C.RED, enabled=self._color) + f" {state.error}")
|
|
1025
|
+
loaded = self._messages_from_state(state)
|
|
1026
|
+
if loaded:
|
|
1027
|
+
self._agent.session_messages = loaded
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
if state.status != self._RunStatus.WAITING or not state.waiting:
|
|
1031
|
+
# Either still RUNNING (max_steps exceeded) or some other non-blocking state.
|
|
1032
|
+
continue
|
|
1033
|
+
|
|
1034
|
+
wait = state.waiting
|
|
1035
|
+
if wait.reason == self._WaitReason.USER:
|
|
1036
|
+
response = self._prompt_user(wait.prompt or "Please respond:", wait.choices)
|
|
1037
|
+
state = self._agent.resume(response)
|
|
1038
|
+
continue
|
|
1039
|
+
|
|
1040
|
+
# Tool approval waits are modeled as EVENT waits with details.tool_calls.
|
|
1041
|
+
details = wait.details or {}
|
|
1042
|
+
tool_calls = details.get("tool_calls")
|
|
1043
|
+
if isinstance(tool_calls, list):
|
|
1044
|
+
self._ui.clear_spinner() # Clear spinner during approval prompt
|
|
1045
|
+
payload = self._approve_and_execute(tool_calls)
|
|
1046
|
+
if payload is None:
|
|
1047
|
+
self._print(_style("\nLeft run waiting (not resumed).", _C.DIM, enabled=self._color))
|
|
1048
|
+
return
|
|
1049
|
+
|
|
1050
|
+
state = self._runtime.resume(
|
|
1051
|
+
workflow=self._agent.workflow,
|
|
1052
|
+
run_id=run_id,
|
|
1053
|
+
wait_key=wait.wait_key,
|
|
1054
|
+
payload=payload,
|
|
1055
|
+
)
|
|
1056
|
+
continue
|
|
1057
|
+
|
|
1058
|
+
self._ui.clear_spinner()
|
|
1059
|
+
self._print(
|
|
1060
|
+
_style("\nWaiting:", _C.YELLOW, enabled=self._color)
|
|
1061
|
+
+ f" {wait.reason.value} ({wait.wait_key})"
|
|
1062
|
+
)
|
|
1063
|
+
return
|
|
1064
|
+
|
|
1065
|
+
def _prompt_user(self, prompt: str, choices: Optional[Sequence[str]]) -> str:
|
|
1066
|
+
self._ui.clear_spinner() # Clear spinner when prompting user
|
|
1067
|
+
if choices:
|
|
1068
|
+
self._print(_style(prompt, _C.MAGENTA, _C.BOLD, enabled=self._color))
|
|
1069
|
+
for i, c in enumerate(choices):
|
|
1070
|
+
self._print(f" [{i+1}] {c}")
|
|
1071
|
+
while True:
|
|
1072
|
+
raw = self._simple_prompt("Choice (number or text): ")
|
|
1073
|
+
if not raw:
|
|
1074
|
+
continue
|
|
1075
|
+
if raw.isdigit():
|
|
1076
|
+
idx = int(raw) - 1
|
|
1077
|
+
if 0 <= idx < len(choices):
|
|
1078
|
+
return str(choices[idx])
|
|
1079
|
+
return raw
|
|
1080
|
+
return self._simple_prompt(prompt + " ")
|
|
1081
|
+
|
|
1082
|
+
def _approve_and_execute(self, tool_calls: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
1083
|
+
if self._auto_approve:
|
|
1084
|
+
return self._tool_runner.execute(tool_calls=tool_calls)
|
|
1085
|
+
|
|
1086
|
+
# If user already said "all" for this session, just execute without UI clutter
|
|
1087
|
+
if self._approve_all_session:
|
|
1088
|
+
return self._tool_runner.execute(tool_calls=tool_calls)
|
|
1089
|
+
|
|
1090
|
+
self._print(_style("\nTool approval required", _C.CYAN, _C.BOLD, enabled=self._color))
|
|
1091
|
+
self._print(_style("─" * 60, _C.DIM, enabled=self._color))
|
|
1092
|
+
|
|
1093
|
+
approve_all = False
|
|
1094
|
+
results: List[Dict[str, Any]] = []
|
|
1095
|
+
|
|
1096
|
+
for tc in tool_calls:
|
|
1097
|
+
name = str(tc.get("name", "") or "")
|
|
1098
|
+
args = dict(tc.get("arguments") or {})
|
|
1099
|
+
call_id = str(tc.get("call_id") or "")
|
|
1100
|
+
|
|
1101
|
+
spec = self._tool_specs.get(name)
|
|
1102
|
+
descr = spec.description if spec else ""
|
|
1103
|
+
|
|
1104
|
+
self._print(_style(f"\n{name}", _C.GREEN, _C.BOLD, enabled=self._color))
|
|
1105
|
+
if descr:
|
|
1106
|
+
self._print(_style(descr, _C.DIM, enabled=self._color))
|
|
1107
|
+
self._print(
|
|
1108
|
+
_style("args:", _C.DIM, enabled=self._color)
|
|
1109
|
+
+ " "
|
|
1110
|
+
+ json.dumps(_truncate_json(args), indent=2, ensure_ascii=False)
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
if not approve_all:
|
|
1114
|
+
while True:
|
|
1115
|
+
choice = self._simple_prompt("Approve? [y]es/[n]o/[a]ll/[e]dit/[q]uit: ").lower()
|
|
1116
|
+
if choice in ("y", "yes"):
|
|
1117
|
+
break
|
|
1118
|
+
if choice in ("a", "all"):
|
|
1119
|
+
approve_all = True
|
|
1120
|
+
self._approve_all_session = True
|
|
1121
|
+
break
|
|
1122
|
+
if choice in ("n", "no"):
|
|
1123
|
+
results.append(
|
|
1124
|
+
{
|
|
1125
|
+
"call_id": call_id,
|
|
1126
|
+
"name": name,
|
|
1127
|
+
"success": False,
|
|
1128
|
+
"output": None,
|
|
1129
|
+
"error": "Rejected by user",
|
|
1130
|
+
}
|
|
1131
|
+
)
|
|
1132
|
+
name = ""
|
|
1133
|
+
break
|
|
1134
|
+
if choice in ("q", "quit"):
|
|
1135
|
+
return None
|
|
1136
|
+
if choice in ("e", "edit"):
|
|
1137
|
+
edited = self._simple_prompt("New arguments (JSON): ")
|
|
1138
|
+
if edited:
|
|
1139
|
+
try:
|
|
1140
|
+
new_args = json.loads(edited)
|
|
1141
|
+
except json.JSONDecodeError as e:
|
|
1142
|
+
self._print(_style(f"Invalid JSON: {e}", _C.YELLOW, enabled=self._color))
|
|
1143
|
+
continue
|
|
1144
|
+
if not isinstance(new_args, dict):
|
|
1145
|
+
self._print(_style("Arguments must be a JSON object.", _C.YELLOW, enabled=self._color))
|
|
1146
|
+
continue
|
|
1147
|
+
args = new_args
|
|
1148
|
+
tc["arguments"] = args
|
|
1149
|
+
self._print(_style("Updated args.", _C.DIM, enabled=self._color))
|
|
1150
|
+
continue
|
|
1151
|
+
|
|
1152
|
+
self._print("Enter y/n/a/e/q.")
|
|
1153
|
+
|
|
1154
|
+
if not name:
|
|
1155
|
+
continue
|
|
1156
|
+
|
|
1157
|
+
# Additional confirmation for shell execution (skip if approve_all is set)
|
|
1158
|
+
if name == "execute_command" and not approve_all:
|
|
1159
|
+
confirm = self._simple_prompt("Type 'run' to execute this command: ").lower()
|
|
1160
|
+
if confirm != "run":
|
|
1161
|
+
results.append(
|
|
1162
|
+
{
|
|
1163
|
+
"call_id": call_id,
|
|
1164
|
+
"name": name,
|
|
1165
|
+
"success": False,
|
|
1166
|
+
"output": None,
|
|
1167
|
+
"error": "Rejected by user",
|
|
1168
|
+
}
|
|
1169
|
+
)
|
|
1170
|
+
continue
|
|
1171
|
+
|
|
1172
|
+
single = {"name": name, "arguments": args, "call_id": call_id}
|
|
1173
|
+
out = self._tool_runner.execute(tool_calls=[single])
|
|
1174
|
+
results.extend(out.get("results") or [])
|
|
1175
|
+
|
|
1176
|
+
return {"mode": "executed", "results": results}
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _truncate_json(value: Any, *, max_str: int = 800, max_list: int = 50, max_dict: int = 50) -> Any:
|
|
1180
|
+
if isinstance(value, str):
|
|
1181
|
+
if len(value) <= max_str:
|
|
1182
|
+
return value
|
|
1183
|
+
head = value[:400]
|
|
1184
|
+
tail = value[-200:] if len(value) > 600 else ""
|
|
1185
|
+
suffix = f"... ({len(value)} chars total)"
|
|
1186
|
+
return head + (("\n" + suffix + "\n" + tail) if tail else ("\n" + suffix))
|
|
1187
|
+
|
|
1188
|
+
if isinstance(value, list):
|
|
1189
|
+
trimmed = value[:max_list]
|
|
1190
|
+
out = [_truncate_json(v, max_str=max_str, max_list=max_list, max_dict=max_dict) for v in trimmed]
|
|
1191
|
+
if len(value) > max_list:
|
|
1192
|
+
out.append(f"... ({len(value)} items total)")
|
|
1193
|
+
return out
|
|
1194
|
+
|
|
1195
|
+
if isinstance(value, dict):
|
|
1196
|
+
items = list(value.items())[:max_dict]
|
|
1197
|
+
out_dict: Dict[str, Any] = {}
|
|
1198
|
+
for k, v in items:
|
|
1199
|
+
out_dict[str(k)] = _truncate_json(v, max_str=max_str, max_list=max_list, max_dict=max_dict)
|
|
1200
|
+
if len(value) > max_dict:
|
|
1201
|
+
out_dict["..."] = f"({len(value)} keys total)"
|
|
1202
|
+
return out_dict
|
|
1203
|
+
|
|
1204
|
+
return value
|