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.
@@ -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