deepagent-code 0.1.3__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.
- deepagent_code/__init__.py +46 -0
- deepagent_code/cli.py +1368 -0
- deepagent_code/utils.py +653 -0
- deepagent_code-0.1.3.dist-info/METADATA +160 -0
- deepagent_code-0.1.3.dist-info/RECORD +9 -0
- deepagent_code-0.1.3.dist-info/WHEEL +5 -0
- deepagent_code-0.1.3.dist-info/entry_points.txt +2 -0
- deepagent_code-0.1.3.dist-info/licenses/LICENSE +21 -0
- deepagent_code-0.1.3.dist-info/top_level.txt +1 -0
deepagent_code/cli.py
ADDED
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for running arbitrary LangGraph agents from the terminal.
|
|
3
|
+
Styled after Claude Code / nanocode.
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
# Platform-specific imports for keyboard input
|
|
18
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
19
|
+
if IS_WINDOWS:
|
|
20
|
+
import msvcrt
|
|
21
|
+
else:
|
|
22
|
+
import termios
|
|
23
|
+
import tty
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
|
|
27
|
+
# Try to import readline for tab completion (not available on all platforms)
|
|
28
|
+
try:
|
|
29
|
+
import readline
|
|
30
|
+
HAS_READLINE = True
|
|
31
|
+
except ImportError:
|
|
32
|
+
HAS_READLINE = False
|
|
33
|
+
|
|
34
|
+
from deepagent_code.utils import (
|
|
35
|
+
prepare_agent_input,
|
|
36
|
+
stream_graph_updates,
|
|
37
|
+
astream_graph_updates,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ANSI color codes (matching nanocode style)
|
|
42
|
+
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
43
|
+
ITALIC, UNDERLINE = "\033[3m", "\033[4m"
|
|
44
|
+
BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
|
|
45
|
+
MAGENTA, WHITE, GRAY = "\033[35m", "\033[37m", "\033[90m"
|
|
46
|
+
|
|
47
|
+
# Bright variants for gradient effects
|
|
48
|
+
BRIGHT_CYAN, BRIGHT_BLUE = "\033[96m", "\033[94m"
|
|
49
|
+
BRIGHT_GREEN, BRIGHT_YELLOW = "\033[92m", "\033[93m"
|
|
50
|
+
|
|
51
|
+
# Spinner frames for thinking animation
|
|
52
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Version info
|
|
56
|
+
__version__ = "0.1.3"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Slash command registry
|
|
60
|
+
class SlashCommand:
|
|
61
|
+
"""Represents a slash command with its handler and metadata."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
name: str,
|
|
66
|
+
handler: callable,
|
|
67
|
+
description: str,
|
|
68
|
+
aliases: Optional[List[str]] = None,
|
|
69
|
+
usage: Optional[str] = None,
|
|
70
|
+
):
|
|
71
|
+
self.name = name
|
|
72
|
+
self.handler = handler
|
|
73
|
+
self.description = description
|
|
74
|
+
self.aliases = aliases or []
|
|
75
|
+
self.usage = usage or f"/{name}"
|
|
76
|
+
|
|
77
|
+
def execute(self, args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
78
|
+
"""Execute the command with given arguments and context."""
|
|
79
|
+
return self.handler(args, context)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CommandRegistry:
|
|
83
|
+
"""Registry for slash commands."""
|
|
84
|
+
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self._commands: Dict[str, SlashCommand] = {}
|
|
87
|
+
self._alias_map: Dict[str, str] = {}
|
|
88
|
+
|
|
89
|
+
def register(self, command: SlashCommand):
|
|
90
|
+
"""Register a slash command."""
|
|
91
|
+
self._commands[command.name] = command
|
|
92
|
+
for alias in command.aliases:
|
|
93
|
+
self._alias_map[alias] = command.name
|
|
94
|
+
|
|
95
|
+
def get(self, name: str) -> Optional[SlashCommand]:
|
|
96
|
+
"""Get a command by name or alias."""
|
|
97
|
+
# Check if it's an alias
|
|
98
|
+
if name in self._alias_map:
|
|
99
|
+
name = self._alias_map[name]
|
|
100
|
+
return self._commands.get(name)
|
|
101
|
+
|
|
102
|
+
def all_commands(self) -> List[SlashCommand]:
|
|
103
|
+
"""Get all registered commands."""
|
|
104
|
+
return list(self._commands.values())
|
|
105
|
+
|
|
106
|
+
def parse_input(self, user_input: str) -> Tuple[Optional[str], str]:
|
|
107
|
+
"""Parse user input to extract command name and arguments.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Tuple of (command_name, arguments) or (None, original_input) if not a command
|
|
111
|
+
"""
|
|
112
|
+
if not user_input.startswith("/"):
|
|
113
|
+
return None, user_input
|
|
114
|
+
|
|
115
|
+
# Split into command and args
|
|
116
|
+
parts = user_input[1:].split(maxsplit=1)
|
|
117
|
+
cmd_name = parts[0].lower() if parts else ""
|
|
118
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
119
|
+
|
|
120
|
+
return cmd_name, args
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Global command registry
|
|
124
|
+
command_registry = CommandRegistry()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def rl_wrap(code: str) -> str:
|
|
128
|
+
"""Wrap ANSI escape code for readline to ignore in length calculations.
|
|
129
|
+
|
|
130
|
+
On terminals, ANSI codes are invisible but counted in string length.
|
|
131
|
+
This causes issues with line wrapping when using input().
|
|
132
|
+
Wrapping with \\001 and \\002 tells readline to ignore these characters.
|
|
133
|
+
"""
|
|
134
|
+
if HAS_READLINE:
|
|
135
|
+
return f"\001{code}\002"
|
|
136
|
+
return code
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def make_prompt(text: str = "❯", color: str = BRIGHT_BLUE) -> str:
|
|
140
|
+
"""Create a prompt string with proper readline escaping for ANSI codes.
|
|
141
|
+
|
|
142
|
+
This prevents line wrapping issues on Windows and other terminals.
|
|
143
|
+
"""
|
|
144
|
+
return f"{rl_wrap(BOLD)}{rl_wrap(color)}{text}{rl_wrap(RESET)} "
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def register_command(
|
|
148
|
+
name: str,
|
|
149
|
+
description: str,
|
|
150
|
+
aliases: Optional[List[str]] = None,
|
|
151
|
+
usage: Optional[str] = None,
|
|
152
|
+
):
|
|
153
|
+
"""Decorator to register a slash command handler."""
|
|
154
|
+
def decorator(func):
|
|
155
|
+
command = SlashCommand(
|
|
156
|
+
name=name,
|
|
157
|
+
handler=func,
|
|
158
|
+
description=description,
|
|
159
|
+
aliases=aliases or [],
|
|
160
|
+
usage=usage,
|
|
161
|
+
)
|
|
162
|
+
command_registry.register(command)
|
|
163
|
+
return func
|
|
164
|
+
return decorator
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Spinner:
|
|
168
|
+
"""A simple terminal spinner for showing activity with elapsed time."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, message: str = "Thinking"):
|
|
171
|
+
self.message = message
|
|
172
|
+
self.running = False
|
|
173
|
+
self.thread = None
|
|
174
|
+
self.frame_idx = 0
|
|
175
|
+
self.start_time = None
|
|
176
|
+
|
|
177
|
+
def _spin(self):
|
|
178
|
+
"""Run the spinner animation with elapsed time display."""
|
|
179
|
+
while self.running:
|
|
180
|
+
frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
|
|
181
|
+
elapsed = time.time() - self.start_time
|
|
182
|
+
elapsed_str = f"{int(elapsed)}s"
|
|
183
|
+
print(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}... {elapsed_str}{RESET}", end="", flush=True)
|
|
184
|
+
self.frame_idx += 1
|
|
185
|
+
time.sleep(0.08)
|
|
186
|
+
|
|
187
|
+
def start(self):
|
|
188
|
+
"""Start the spinner."""
|
|
189
|
+
self.running = True
|
|
190
|
+
self.start_time = time.time()
|
|
191
|
+
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
192
|
+
self.thread.start()
|
|
193
|
+
|
|
194
|
+
def stop(self):
|
|
195
|
+
"""Stop the spinner and clear the line."""
|
|
196
|
+
self.running = False
|
|
197
|
+
if self.thread:
|
|
198
|
+
self.thread.join(timeout=0.2)
|
|
199
|
+
# Clear the spinner line
|
|
200
|
+
print("\r\033[2K", end="", flush=True)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_terminal_width() -> int:
|
|
204
|
+
"""Get terminal width, capped at 100 for readability."""
|
|
205
|
+
try:
|
|
206
|
+
return min(os.get_terminal_size().columns, 100)
|
|
207
|
+
except OSError:
|
|
208
|
+
return 80
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def separator(style: str = "light") -> str:
|
|
212
|
+
"""Return a styled separator line.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
style: 'light' for thin line, 'heavy' for thick line, 'dots' for dotted
|
|
216
|
+
"""
|
|
217
|
+
width = get_terminal_width()
|
|
218
|
+
if style == "heavy":
|
|
219
|
+
return f"{DIM}{'━' * width}{RESET}"
|
|
220
|
+
elif style == "dots":
|
|
221
|
+
return f"{DIM}{'·' * width}{RESET}"
|
|
222
|
+
else:
|
|
223
|
+
return f"{DIM}{'─' * width}{RESET}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def print_welcome():
|
|
227
|
+
"""Print a welcome message with tips."""
|
|
228
|
+
tips = [
|
|
229
|
+
f"Type {CYAN}/help{RESET} for commands",
|
|
230
|
+
f"Use {CYAN}/c{RESET} to clear conversation",
|
|
231
|
+
f"Press {CYAN}Ctrl+C{RESET} to exit",
|
|
232
|
+
f"Press {CYAN}Tab{RESET} to autocomplete commands",
|
|
233
|
+
]
|
|
234
|
+
tip = tips[int(time.time()) % len(tips)] # Rotate tips
|
|
235
|
+
print(f"\n{DIM}Tip: {tip}{RESET}\n")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def print_goodbye():
|
|
239
|
+
"""Print a goodbye message."""
|
|
240
|
+
print(f"\n{DIM}Goodbye!{RESET}\n")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_agent_name(graph) -> str:
|
|
244
|
+
"""Extract agent name from graph object, defaulting to 'AgentCode'."""
|
|
245
|
+
# Try common attribute names for agent/graph name
|
|
246
|
+
for attr in ('name', 'agent_name', '_name', '__name__'):
|
|
247
|
+
if hasattr(graph, attr):
|
|
248
|
+
name = getattr(graph, attr)
|
|
249
|
+
if name and isinstance(name, str):
|
|
250
|
+
return name
|
|
251
|
+
# Check if it's a compiled graph with a name in builder
|
|
252
|
+
if hasattr(graph, 'builder') and hasattr(graph.builder, 'name'):
|
|
253
|
+
name = graph.builder.name
|
|
254
|
+
if name and isinstance(name, str):
|
|
255
|
+
return name
|
|
256
|
+
return "AgentCode"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def print_header_box(agent_name: str, cwd: str):
|
|
260
|
+
"""Print an elegant header with the agent name and version."""
|
|
261
|
+
term_width = get_terminal_width()
|
|
262
|
+
|
|
263
|
+
# Box drawing characters
|
|
264
|
+
TL, TR, BL, BR = "╭", "╮", "╰", "╯" # corners
|
|
265
|
+
H, V = "─", "│" # horizontal and vertical
|
|
266
|
+
|
|
267
|
+
# Calculate inner width (accounting for borders and padding)
|
|
268
|
+
inner_width = term_width - 4 # 2 for borders, 2 for padding
|
|
269
|
+
|
|
270
|
+
# Build the header content
|
|
271
|
+
title_line = agent_name.center(inner_width)
|
|
272
|
+
cwd_display = cwd if len(cwd) <= inner_width else "..." + cwd[-(inner_width - 3):]
|
|
273
|
+
cwd_line = cwd_display.center(inner_width)
|
|
274
|
+
|
|
275
|
+
# Print the box with gradient-style coloring
|
|
276
|
+
print()
|
|
277
|
+
print(f"{BRIGHT_CYAN}{TL}{H * (term_width - 2)}{TR}{RESET}")
|
|
278
|
+
print(f"{BRIGHT_CYAN}{V}{RESET} {BOLD}{BRIGHT_CYAN}{title_line}{RESET} {BRIGHT_CYAN}{V}{RESET}")
|
|
279
|
+
print(f"{CYAN}{V}{RESET} {DIM}{cwd_line}{RESET} {CYAN}{V}{RESET}")
|
|
280
|
+
print(f"{CYAN}{BL}{H * (term_width - 2)}{BR}{RESET}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def render_markdown(text: str) -> str:
|
|
284
|
+
"""Render markdown formatting for terminal display.
|
|
285
|
+
|
|
286
|
+
Supports: **bold**, *italic*, `code`, [links](url)
|
|
287
|
+
"""
|
|
288
|
+
# Bold: **text**
|
|
289
|
+
text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
|
|
290
|
+
# Italic: *text* (but not inside **)
|
|
291
|
+
text = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", f"{ITALIC}\\1{RESET}", text)
|
|
292
|
+
# Inline code: `code`
|
|
293
|
+
text = re.sub(r"`([^`]+?)`", f"{CYAN}\\1{RESET}", text)
|
|
294
|
+
# Links: [text](url) - show text in underline
|
|
295
|
+
text = re.sub(r"\[([^\]]+?)\]\([^)]+?\)", f"{UNDERLINE}\\1{RESET}", text)
|
|
296
|
+
return text
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def parse_agent_spec(agent_spec: str) -> Tuple[str, str]:
|
|
300
|
+
"""
|
|
301
|
+
Parse DEEPAGENT_AGENT_SPEC format: path/to/file.py:variable_name.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
agent_spec: Agent specification string
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Tuple of (file_path, variable_name)
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: If format is invalid
|
|
311
|
+
"""
|
|
312
|
+
if ':' not in agent_spec:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"Invalid agent spec format: '{agent_spec}'. "
|
|
315
|
+
f"Expected format: 'path/to/file.py:variable_name'"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
parts = agent_spec.rsplit(':', 1)
|
|
319
|
+
file_path = parts[0]
|
|
320
|
+
variable_name = parts[1]
|
|
321
|
+
|
|
322
|
+
if not file_path.endswith('.py'):
|
|
323
|
+
raise ValueError(f"Agent spec file must be a .py file: {file_path}")
|
|
324
|
+
|
|
325
|
+
return file_path, variable_name
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def load_graph_from_file(file_path: str, graph_name: str = "graph"):
|
|
329
|
+
"""
|
|
330
|
+
Dynamically load a LangGraph graph from a Python file.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
file_path: Path to the Python file containing the graph
|
|
334
|
+
graph_name: Name of the graph variable (default: "graph")
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
The loaded graph object
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
FileNotFoundError: If the file doesn't exist
|
|
341
|
+
AttributeError: If the graph variable doesn't exist in the module
|
|
342
|
+
Exception: For other loading errors
|
|
343
|
+
"""
|
|
344
|
+
file_path = Path(file_path).resolve()
|
|
345
|
+
|
|
346
|
+
if not file_path.exists():
|
|
347
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
348
|
+
|
|
349
|
+
# Load the module
|
|
350
|
+
spec = importlib.util.spec_from_file_location("graph_module", file_path)
|
|
351
|
+
if spec is None or spec.loader is None:
|
|
352
|
+
raise Exception(f"Could not load module from {file_path}")
|
|
353
|
+
|
|
354
|
+
module = importlib.util.module_from_spec(spec)
|
|
355
|
+
sys.modules["graph_module"] = module
|
|
356
|
+
spec.loader.exec_module(module)
|
|
357
|
+
|
|
358
|
+
# Get the graph object
|
|
359
|
+
if not hasattr(module, graph_name):
|
|
360
|
+
raise AttributeError(
|
|
361
|
+
f"Module does not have a '{graph_name}' variable. "
|
|
362
|
+
f"Available: {', '.join(dir(module))}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
graph = getattr(module, graph_name)
|
|
366
|
+
return graph
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def load_graph_from_module(module_path: str, graph_name: str = "graph"):
|
|
370
|
+
"""
|
|
371
|
+
Dynamically load a LangGraph graph from a Python module path.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
module_path: Dotted module path (e.g., "mypackage.agents.chatbot")
|
|
375
|
+
graph_name: Name of the graph variable (default: "graph")
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
The loaded graph object
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
ModuleNotFoundError: If the module doesn't exist
|
|
382
|
+
AttributeError: If the graph variable doesn't exist in the module
|
|
383
|
+
"""
|
|
384
|
+
import importlib
|
|
385
|
+
module = importlib.import_module(module_path)
|
|
386
|
+
|
|
387
|
+
if not hasattr(module, graph_name):
|
|
388
|
+
raise AttributeError(
|
|
389
|
+
f"Module '{module_path}' does not have a '{graph_name}' variable. "
|
|
390
|
+
f"Available: {', '.join(dir(module))}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
graph = getattr(module, graph_name)
|
|
394
|
+
return graph
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def load_graph(spec: str, default_graph_name: str = "graph"):
|
|
398
|
+
"""
|
|
399
|
+
Load a graph from either a file path or module path.
|
|
400
|
+
|
|
401
|
+
Supports formats:
|
|
402
|
+
- path/to/file.py (uses default_graph_name)
|
|
403
|
+
- path/to/file.py:graph_name
|
|
404
|
+
- package.module (uses default_graph_name)
|
|
405
|
+
- package.module:graph_name
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
spec: File path or module path, optionally with :graph_name suffix
|
|
409
|
+
default_graph_name: Graph name to use if not specified in spec
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
The loaded graph object
|
|
413
|
+
"""
|
|
414
|
+
# Parse the spec to extract graph name if present
|
|
415
|
+
if ':' in spec:
|
|
416
|
+
path_or_module, graph_name = spec.rsplit(':', 1)
|
|
417
|
+
if not graph_name:
|
|
418
|
+
graph_name = default_graph_name
|
|
419
|
+
else:
|
|
420
|
+
path_or_module = spec
|
|
421
|
+
graph_name = default_graph_name
|
|
422
|
+
|
|
423
|
+
# Determine if it's a file path or module path
|
|
424
|
+
# File paths end with .py or contain path separators
|
|
425
|
+
is_file_path = (
|
|
426
|
+
path_or_module.endswith('.py') or
|
|
427
|
+
'/' in path_or_module or
|
|
428
|
+
'\\' in path_or_module or
|
|
429
|
+
Path(path_or_module).exists()
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if is_file_path:
|
|
433
|
+
return load_graph_from_file(path_or_module, graph_name), graph_name
|
|
434
|
+
else:
|
|
435
|
+
return load_graph_from_module(path_or_module, graph_name), graph_name
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def get_tool_arg_preview(args: Dict[str, Any]) -> str:
|
|
439
|
+
"""Get a preview of the first argument value (nanocode style)."""
|
|
440
|
+
if not args:
|
|
441
|
+
return ""
|
|
442
|
+
# Get first value
|
|
443
|
+
first_val = str(list(args.values())[0])
|
|
444
|
+
# Truncate if needed
|
|
445
|
+
if len(first_val) > 50:
|
|
446
|
+
return first_val[:50] + "..."
|
|
447
|
+
return first_val
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def format_result_preview(result: str) -> str:
|
|
451
|
+
"""Format a result preview with line count indicator."""
|
|
452
|
+
if not result:
|
|
453
|
+
return "(empty)"
|
|
454
|
+
lines = result.split("\n")
|
|
455
|
+
preview = lines[0][:60]
|
|
456
|
+
if len(lines) > 1:
|
|
457
|
+
preview += f" ... +{len(lines) - 1} lines"
|
|
458
|
+
elif len(lines[0]) > 60:
|
|
459
|
+
preview += "..."
|
|
460
|
+
return preview
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def format_duration(seconds: float) -> str:
|
|
464
|
+
"""Format duration in human-readable format."""
|
|
465
|
+
if seconds < 1:
|
|
466
|
+
return f"{seconds * 1000:.0f}ms"
|
|
467
|
+
elif seconds < 60:
|
|
468
|
+
return f"{seconds:.1f}s"
|
|
469
|
+
else:
|
|
470
|
+
minutes = int(seconds // 60)
|
|
471
|
+
secs = seconds % 60
|
|
472
|
+
return f"{minutes}m {secs:.1f}s"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def print_timing(duration: float, verbose: bool = False):
|
|
476
|
+
"""Print response timing information."""
|
|
477
|
+
formatted = format_duration(duration)
|
|
478
|
+
if verbose:
|
|
479
|
+
print(f"\n{DIM}Response time: {formatted}{RESET}")
|
|
480
|
+
else:
|
|
481
|
+
print(f"\n{DIM}{formatted}{RESET}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
|
|
485
|
+
"""
|
|
486
|
+
Pretty print a chunk from the stream using Claude Code styling.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
chunk: The chunk dictionary
|
|
490
|
+
verbose: Whether to show verbose output
|
|
491
|
+
"""
|
|
492
|
+
status = chunk.get("status")
|
|
493
|
+
|
|
494
|
+
if status == "streaming":
|
|
495
|
+
# Handle text chunks - cyan bullet with text
|
|
496
|
+
if "chunk" in chunk:
|
|
497
|
+
text = chunk["chunk"]
|
|
498
|
+
node = chunk.get("node", "unknown")
|
|
499
|
+
if verbose:
|
|
500
|
+
print(f"{DIM}[{node}]{RESET} {text}", end="")
|
|
501
|
+
else:
|
|
502
|
+
# Print text output with cyan bullet
|
|
503
|
+
print(f"{CYAN}⏺{RESET} {render_markdown(text)}", end="")
|
|
504
|
+
|
|
505
|
+
# Handle tool calls - green tool name
|
|
506
|
+
elif "tool_calls" in chunk:
|
|
507
|
+
for tool_call in chunk["tool_calls"]:
|
|
508
|
+
tool_name = tool_call["name"]
|
|
509
|
+
args = tool_call.get("args", {})
|
|
510
|
+
arg_preview = get_tool_arg_preview(args)
|
|
511
|
+
|
|
512
|
+
print(f"\n{GREEN}● {tool_name}{RESET}")
|
|
513
|
+
if arg_preview:
|
|
514
|
+
print(f" {DIM}└─ {arg_preview}{RESET}")
|
|
515
|
+
|
|
516
|
+
# Handle tool results - indented with result preview
|
|
517
|
+
elif "tool_result" in chunk:
|
|
518
|
+
result = chunk.get("tool_result", "")
|
|
519
|
+
preview = format_result_preview(str(result))
|
|
520
|
+
print(f" {DIM} ↳ {preview}{RESET}")
|
|
521
|
+
|
|
522
|
+
elif status == "interrupt":
|
|
523
|
+
interrupt_data = chunk.get("interrupt", {})
|
|
524
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
525
|
+
|
|
526
|
+
print(f"\n{YELLOW}⚠ Action Required{RESET}")
|
|
527
|
+
if action_requests:
|
|
528
|
+
for i, action in enumerate(action_requests):
|
|
529
|
+
tool = action.get('tool', 'unknown')
|
|
530
|
+
args_preview = get_tool_arg_preview(action.get('args', {}))
|
|
531
|
+
print(f" {DIM}{i + 1}. {tool}{RESET}")
|
|
532
|
+
if args_preview:
|
|
533
|
+
print(f" {DIM}└─ {args_preview}{RESET}")
|
|
534
|
+
|
|
535
|
+
elif status == "complete":
|
|
536
|
+
pass # No output on complete (nanocode style)
|
|
537
|
+
|
|
538
|
+
elif status == "error":
|
|
539
|
+
error_msg = chunk.get("error", "Unknown error")
|
|
540
|
+
print(f"\n{RED}✗ Error: {error_msg}{RESET}")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def get_key() -> str:
|
|
544
|
+
"""Read a single keypress from stdin (cross-platform)."""
|
|
545
|
+
if IS_WINDOWS:
|
|
546
|
+
# Windows implementation using msvcrt
|
|
547
|
+
ch = msvcrt.getch()
|
|
548
|
+
if ch in (b'\x00', b'\xe0'): # Special keys (arrows, function keys)
|
|
549
|
+
ch2 = msvcrt.getch()
|
|
550
|
+
if ch2 == b'H':
|
|
551
|
+
return 'up'
|
|
552
|
+
elif ch2 == b'P':
|
|
553
|
+
return 'down'
|
|
554
|
+
return ch2.decode('utf-8', errors='ignore')
|
|
555
|
+
elif ch == b'\r':
|
|
556
|
+
return 'enter'
|
|
557
|
+
elif ch == b'\x03': # Ctrl+C
|
|
558
|
+
return 'ctrl-c'
|
|
559
|
+
return ch.decode('utf-8', errors='ignore')
|
|
560
|
+
else:
|
|
561
|
+
# Unix implementation using termios/tty
|
|
562
|
+
fd = sys.stdin.fileno()
|
|
563
|
+
old_settings = termios.tcgetattr(fd)
|
|
564
|
+
try:
|
|
565
|
+
tty.setraw(fd)
|
|
566
|
+
ch = sys.stdin.read(1)
|
|
567
|
+
# Handle escape sequences (arrow keys)
|
|
568
|
+
if ch == '\x1b':
|
|
569
|
+
ch2 = sys.stdin.read(1)
|
|
570
|
+
if ch2 == '[':
|
|
571
|
+
ch3 = sys.stdin.read(1)
|
|
572
|
+
if ch3 == 'A':
|
|
573
|
+
return 'up'
|
|
574
|
+
elif ch3 == 'B':
|
|
575
|
+
return 'down'
|
|
576
|
+
elif ch == '\r' or ch == '\n':
|
|
577
|
+
return 'enter'
|
|
578
|
+
elif ch == '\x03': # Ctrl+C
|
|
579
|
+
return 'ctrl-c'
|
|
580
|
+
return ch
|
|
581
|
+
finally:
|
|
582
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def select_option(options: List[str], prompt: str = "Select an option:") -> int:
|
|
586
|
+
"""
|
|
587
|
+
Interactive option selector using arrow keys.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
options: List of option strings to display
|
|
591
|
+
prompt: Prompt to show above options
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
Index of selected option (0-based)
|
|
595
|
+
"""
|
|
596
|
+
selected = 0
|
|
597
|
+
num_options = len(options)
|
|
598
|
+
|
|
599
|
+
# Hide cursor
|
|
600
|
+
print("\033[?25l", end="")
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
print(f"\n{BOLD}{prompt}{RESET}")
|
|
604
|
+
|
|
605
|
+
# Print initial options
|
|
606
|
+
for i, opt in enumerate(options):
|
|
607
|
+
if i == selected:
|
|
608
|
+
print(f" {CYAN}❯ {opt}{RESET}")
|
|
609
|
+
else:
|
|
610
|
+
print(f" {DIM}{opt}{RESET}")
|
|
611
|
+
|
|
612
|
+
while True:
|
|
613
|
+
key = get_key()
|
|
614
|
+
|
|
615
|
+
if key == 'up' and selected > 0:
|
|
616
|
+
selected -= 1
|
|
617
|
+
elif key == 'down' and selected < num_options - 1:
|
|
618
|
+
selected += 1
|
|
619
|
+
elif key == 'enter':
|
|
620
|
+
break
|
|
621
|
+
elif key == 'ctrl-c':
|
|
622
|
+
print("\033[?25h", end="") # Show cursor
|
|
623
|
+
sys.exit(0)
|
|
624
|
+
|
|
625
|
+
# Move cursor up to redraw options
|
|
626
|
+
print(f"\033[{num_options}A", end="")
|
|
627
|
+
|
|
628
|
+
# Redraw options
|
|
629
|
+
for i, opt in enumerate(options):
|
|
630
|
+
# Clear line and print option
|
|
631
|
+
print("\033[2K", end="") # Clear line
|
|
632
|
+
if i == selected:
|
|
633
|
+
print(f" {CYAN}❯ {opt}{RESET}")
|
|
634
|
+
else:
|
|
635
|
+
print(f" {DIM}{opt}{RESET}")
|
|
636
|
+
|
|
637
|
+
return selected
|
|
638
|
+
finally:
|
|
639
|
+
# Show cursor
|
|
640
|
+
print("\033[?25h", end="")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def handle_interrupt_input(num_actions: int = 1) -> List[Dict[str, Any]]:
|
|
644
|
+
"""
|
|
645
|
+
Handle user input for interrupt decisions using arrow key navigation.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
num_actions: Number of pending tool calls that need decisions
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
List of decision objects (one for each pending action)
|
|
652
|
+
"""
|
|
653
|
+
options = [
|
|
654
|
+
"Approve all actions",
|
|
655
|
+
"Reject all actions",
|
|
656
|
+
"Provide custom decision (JSON)",
|
|
657
|
+
"Exit",
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
choice = select_option(options, "How would you like to proceed?")
|
|
661
|
+
|
|
662
|
+
if choice == 0:
|
|
663
|
+
# Return approve decision for each pending action
|
|
664
|
+
return [{"type": "approve"} for _ in range(num_actions)]
|
|
665
|
+
elif choice == 1:
|
|
666
|
+
# Return reject decision for each pending action
|
|
667
|
+
return [{"type": "reject"} for _ in range(num_actions)]
|
|
668
|
+
elif choice == 2:
|
|
669
|
+
print("Enter your decision as JSON (will be applied to all actions):")
|
|
670
|
+
json_str = input(make_prompt("❯", BLUE)).strip()
|
|
671
|
+
try:
|
|
672
|
+
decision = json.loads(json_str)
|
|
673
|
+
return [decision for _ in range(num_actions)]
|
|
674
|
+
except json.JSONDecodeError as e:
|
|
675
|
+
print(f"{RED}⏺ Invalid JSON: {e}{RESET}")
|
|
676
|
+
return [{"type": "reject"} for _ in range(num_actions)]
|
|
677
|
+
else:
|
|
678
|
+
sys.exit(0)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
async def run_single_turn_async(
|
|
682
|
+
graph,
|
|
683
|
+
message: str,
|
|
684
|
+
config: Optional[Dict[str, Any]] = None,
|
|
685
|
+
interactive: bool = True,
|
|
686
|
+
verbose: bool = False,
|
|
687
|
+
stream_mode: str = "updates",
|
|
688
|
+
) -> float:
|
|
689
|
+
"""Run a single turn of an async LangGraph graph. Returns total duration in seconds."""
|
|
690
|
+
input_data = prepare_agent_input(message=message)
|
|
691
|
+
start_time = time.time()
|
|
692
|
+
|
|
693
|
+
while True:
|
|
694
|
+
has_interrupt = False
|
|
695
|
+
num_pending_actions = 0
|
|
696
|
+
first_chunk = True
|
|
697
|
+
spinner = Spinner("Thinking")
|
|
698
|
+
spinner.start()
|
|
699
|
+
|
|
700
|
+
async for chunk in astream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
|
|
701
|
+
# Stop spinner on first chunk
|
|
702
|
+
if first_chunk:
|
|
703
|
+
spinner.stop()
|
|
704
|
+
first_chunk = False
|
|
705
|
+
|
|
706
|
+
print_chunk(chunk, verbose=verbose)
|
|
707
|
+
|
|
708
|
+
if chunk.get("status") == "interrupt":
|
|
709
|
+
has_interrupt = True
|
|
710
|
+
# Count pending action requests
|
|
711
|
+
interrupt_data = chunk.get("interrupt", {})
|
|
712
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
713
|
+
num_pending_actions = len(action_requests) if action_requests else 1
|
|
714
|
+
|
|
715
|
+
# Ensure spinner is stopped even if no chunks received
|
|
716
|
+
if first_chunk:
|
|
717
|
+
spinner.stop()
|
|
718
|
+
|
|
719
|
+
if has_interrupt and interactive:
|
|
720
|
+
decisions = handle_interrupt_input(num_pending_actions)
|
|
721
|
+
input_data = prepare_agent_input(decisions=decisions)
|
|
722
|
+
else:
|
|
723
|
+
break
|
|
724
|
+
|
|
725
|
+
return time.time() - start_time
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def run_single_turn_sync(
|
|
729
|
+
graph,
|
|
730
|
+
message: str,
|
|
731
|
+
config: Optional[Dict[str, Any]] = None,
|
|
732
|
+
interactive: bool = True,
|
|
733
|
+
verbose: bool = False,
|
|
734
|
+
stream_mode: str = "updates",
|
|
735
|
+
) -> float:
|
|
736
|
+
"""Run a single turn of a sync LangGraph graph. Returns total duration in seconds."""
|
|
737
|
+
input_data = prepare_agent_input(message=message)
|
|
738
|
+
start_time = time.time()
|
|
739
|
+
|
|
740
|
+
while True:
|
|
741
|
+
has_interrupt = False
|
|
742
|
+
num_pending_actions = 0
|
|
743
|
+
first_chunk = True
|
|
744
|
+
spinner = Spinner("Thinking")
|
|
745
|
+
spinner.start()
|
|
746
|
+
|
|
747
|
+
for chunk in stream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
|
|
748
|
+
# Stop spinner on first chunk
|
|
749
|
+
if first_chunk:
|
|
750
|
+
spinner.stop()
|
|
751
|
+
first_chunk = False
|
|
752
|
+
|
|
753
|
+
print_chunk(chunk, verbose=verbose)
|
|
754
|
+
|
|
755
|
+
if chunk.get("status") == "interrupt":
|
|
756
|
+
has_interrupt = True
|
|
757
|
+
# Count pending action requests
|
|
758
|
+
interrupt_data = chunk.get("interrupt", {})
|
|
759
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
760
|
+
num_pending_actions = len(action_requests) if action_requests else 1
|
|
761
|
+
|
|
762
|
+
# Ensure spinner is stopped even if no chunks received
|
|
763
|
+
if first_chunk:
|
|
764
|
+
spinner.stop()
|
|
765
|
+
|
|
766
|
+
if has_interrupt and interactive:
|
|
767
|
+
decisions = handle_interrupt_input(num_pending_actions)
|
|
768
|
+
input_data = prepare_agent_input(decisions=decisions)
|
|
769
|
+
else:
|
|
770
|
+
break
|
|
771
|
+
|
|
772
|
+
return time.time() - start_time
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def print_help():
|
|
776
|
+
"""Print formatted help information."""
|
|
777
|
+
print(f"\n{BOLD}{BRIGHT_CYAN}Commands{RESET}")
|
|
778
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
779
|
+
|
|
780
|
+
# Get all registered commands and display them
|
|
781
|
+
commands = command_registry.all_commands()
|
|
782
|
+
for cmd in sorted(commands, key=lambda c: c.name):
|
|
783
|
+
aliases_str = ""
|
|
784
|
+
if cmd.aliases:
|
|
785
|
+
aliases_str = f", {CYAN}/{RESET}, {CYAN}/".join([""] + cmd.aliases)[4:]
|
|
786
|
+
print(f" {CYAN}/{cmd.name}{RESET}{aliases_str}")
|
|
787
|
+
print(f" {DIM}{cmd.description}{RESET}")
|
|
788
|
+
|
|
789
|
+
print()
|
|
790
|
+
print(f"{BOLD}{BRIGHT_CYAN}Shortcuts{RESET}")
|
|
791
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
792
|
+
print(f" {CYAN}Tab{RESET} Autocomplete commands")
|
|
793
|
+
print(f" {CYAN}Ctrl+C{RESET} Exit at any time")
|
|
794
|
+
print(f" {CYAN}↑/↓{RESET} Navigate options")
|
|
795
|
+
print()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# --- Built-in Slash Commands ---
|
|
799
|
+
|
|
800
|
+
@register_command(
|
|
801
|
+
name="help",
|
|
802
|
+
description="Show this help message",
|
|
803
|
+
aliases=["h", "?"],
|
|
804
|
+
)
|
|
805
|
+
def cmd_help(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
806
|
+
"""Display help information."""
|
|
807
|
+
if args:
|
|
808
|
+
# Show help for a specific command
|
|
809
|
+
cmd = command_registry.get(args)
|
|
810
|
+
if cmd:
|
|
811
|
+
print(f"\n{BOLD}{BRIGHT_CYAN}/{cmd.name}{RESET}")
|
|
812
|
+
print(f" {cmd.description}")
|
|
813
|
+
if cmd.aliases:
|
|
814
|
+
print(f" {DIM}Aliases: /{', /'.join(cmd.aliases)}{RESET}")
|
|
815
|
+
if cmd.usage:
|
|
816
|
+
print(f" {DIM}Usage: {cmd.usage}{RESET}")
|
|
817
|
+
print()
|
|
818
|
+
else:
|
|
819
|
+
print(f"{YELLOW}Unknown command: /{args}{RESET}")
|
|
820
|
+
else:
|
|
821
|
+
print_help()
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@register_command(
|
|
826
|
+
name="quit",
|
|
827
|
+
description="Exit the CLI",
|
|
828
|
+
aliases=["q", "exit"],
|
|
829
|
+
)
|
|
830
|
+
def cmd_quit(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
831
|
+
"""Exit the CLI."""
|
|
832
|
+
return "exit" # Special return value to signal exit
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@register_command(
|
|
836
|
+
name="clear",
|
|
837
|
+
description="Clear conversation history",
|
|
838
|
+
aliases=["c"],
|
|
839
|
+
)
|
|
840
|
+
def cmd_clear(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
841
|
+
"""Clear the conversation history."""
|
|
842
|
+
context["config"]["configurable"]["thread_id"] = str(uuid.uuid4())
|
|
843
|
+
print(f"\n{GREEN}✓ Conversation cleared{RESET}\n")
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@register_command(
|
|
848
|
+
name="version",
|
|
849
|
+
description="Show version information",
|
|
850
|
+
aliases=["v"],
|
|
851
|
+
)
|
|
852
|
+
def cmd_version(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
853
|
+
"""Display version information."""
|
|
854
|
+
print(f"\n{BOLD}{BRIGHT_CYAN}deepagent-code{RESET} v{__version__}")
|
|
855
|
+
agent_name = context.get("agent_name", "Unknown")
|
|
856
|
+
print(f"{DIM}Agent: {agent_name}{RESET}\n")
|
|
857
|
+
return None
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
@register_command(
|
|
861
|
+
name="status",
|
|
862
|
+
description="Show current session status",
|
|
863
|
+
aliases=["s"],
|
|
864
|
+
)
|
|
865
|
+
def cmd_status(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
866
|
+
"""Display current session status."""
|
|
867
|
+
config = context.get("config", {})
|
|
868
|
+
thread_id = config.get("configurable", {}).get("thread_id", "N/A")
|
|
869
|
+
agent_name = context.get("agent_name", "Unknown")
|
|
870
|
+
verbose = context.get("verbose", False)
|
|
871
|
+
use_async = context.get("use_async", False)
|
|
872
|
+
stream_mode = context.get("stream_mode", "updates")
|
|
873
|
+
|
|
874
|
+
print(f"\n{BOLD}{BRIGHT_CYAN}Session Status{RESET}")
|
|
875
|
+
print(f"{DIM}{'─' * 30}{RESET}")
|
|
876
|
+
print(f" {DIM}Agent:{RESET} {agent_name}")
|
|
877
|
+
print(f" {DIM}Thread ID:{RESET} {thread_id[:8]}...")
|
|
878
|
+
print(f" {DIM}Mode:{RESET} {'async' if use_async else 'sync'}")
|
|
879
|
+
print(f" {DIM}Stream:{RESET} {stream_mode}")
|
|
880
|
+
print(f" {DIM}Verbose:{RESET} {'on' if verbose else 'off'}")
|
|
881
|
+
print(f" {DIM}CWD:{RESET} {os.getcwd()}")
|
|
882
|
+
print()
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
@register_command(
|
|
887
|
+
name="config",
|
|
888
|
+
description="Show or set configuration",
|
|
889
|
+
aliases=["cfg"],
|
|
890
|
+
usage="/config [key] [value]",
|
|
891
|
+
)
|
|
892
|
+
def cmd_config(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
893
|
+
"""Show or modify configuration."""
|
|
894
|
+
config = context.get("config", {})
|
|
895
|
+
|
|
896
|
+
if not args:
|
|
897
|
+
# Show current config
|
|
898
|
+
print(f"\n{BOLD}{BRIGHT_CYAN}Configuration{RESET}")
|
|
899
|
+
print(f"{DIM}{'─' * 30}{RESET}")
|
|
900
|
+
for key, value in config.items():
|
|
901
|
+
if isinstance(value, dict):
|
|
902
|
+
print(f" {CYAN}{key}:{RESET}")
|
|
903
|
+
for k, v in value.items():
|
|
904
|
+
# Truncate long values
|
|
905
|
+
v_str = str(v)
|
|
906
|
+
if len(v_str) > 30:
|
|
907
|
+
v_str = v_str[:30] + "..."
|
|
908
|
+
print(f" {DIM}{k}:{RESET} {v_str}")
|
|
909
|
+
else:
|
|
910
|
+
print(f" {CYAN}{key}:{RESET} {value}")
|
|
911
|
+
print()
|
|
912
|
+
else:
|
|
913
|
+
parts = args.split(maxsplit=1)
|
|
914
|
+
if len(parts) == 1:
|
|
915
|
+
# Show specific config key
|
|
916
|
+
key = parts[0]
|
|
917
|
+
if key in config:
|
|
918
|
+
print(f"\n{CYAN}{key}:{RESET} {config[key]}\n")
|
|
919
|
+
elif "configurable" in config and key in config["configurable"]:
|
|
920
|
+
print(f"\n{CYAN}{key}:{RESET} {config['configurable'][key]}\n")
|
|
921
|
+
else:
|
|
922
|
+
print(f"{YELLOW}Unknown config key: {key}{RESET}")
|
|
923
|
+
else:
|
|
924
|
+
# Set config value
|
|
925
|
+
key, value = parts
|
|
926
|
+
if key == "verbose":
|
|
927
|
+
context["verbose"] = value.lower() in ("true", "1", "on", "yes")
|
|
928
|
+
print(f"{GREEN}✓ Set verbose = {context['verbose']}{RESET}")
|
|
929
|
+
else:
|
|
930
|
+
print(f"{YELLOW}Cannot modify {key} at runtime{RESET}")
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@register_command(
|
|
935
|
+
name="history",
|
|
936
|
+
description="Show recent messages (if available)",
|
|
937
|
+
aliases=["hist"],
|
|
938
|
+
)
|
|
939
|
+
def cmd_history(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
940
|
+
"""Display conversation history if available."""
|
|
941
|
+
graph = context.get("graph")
|
|
942
|
+
config = context.get("config", {})
|
|
943
|
+
|
|
944
|
+
if graph is None:
|
|
945
|
+
print(f"{YELLOW}No graph available{RESET}")
|
|
946
|
+
return None
|
|
947
|
+
|
|
948
|
+
try:
|
|
949
|
+
# Try to get state from the graph's checkpointer
|
|
950
|
+
if hasattr(graph, "get_state"):
|
|
951
|
+
state = graph.get_state(config)
|
|
952
|
+
if state and hasattr(state, "values"):
|
|
953
|
+
messages = state.values.get("messages", [])
|
|
954
|
+
if messages:
|
|
955
|
+
print(f"\n{BOLD}{BRIGHT_CYAN}Conversation History{RESET}")
|
|
956
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
957
|
+
|
|
958
|
+
# Show last N messages
|
|
959
|
+
limit = 10
|
|
960
|
+
if args:
|
|
961
|
+
try:
|
|
962
|
+
limit = int(args)
|
|
963
|
+
except ValueError:
|
|
964
|
+
pass
|
|
965
|
+
|
|
966
|
+
for msg in messages[-limit:]:
|
|
967
|
+
role = getattr(msg, "type", "unknown")
|
|
968
|
+
content = getattr(msg, "content", str(msg))
|
|
969
|
+
|
|
970
|
+
if role == "human":
|
|
971
|
+
print(f"\n {BRIGHT_BLUE}You:{RESET}")
|
|
972
|
+
elif role == "ai":
|
|
973
|
+
print(f"\n {BRIGHT_CYAN}Agent:{RESET}")
|
|
974
|
+
else:
|
|
975
|
+
print(f"\n {DIM}{role}:{RESET}")
|
|
976
|
+
|
|
977
|
+
# Truncate long content
|
|
978
|
+
if len(content) > 200:
|
|
979
|
+
content = content[:200] + "..."
|
|
980
|
+
print(f" {DIM}{content}{RESET}")
|
|
981
|
+
print()
|
|
982
|
+
else:
|
|
983
|
+
print(f"{DIM}No messages in history{RESET}")
|
|
984
|
+
else:
|
|
985
|
+
print(f"{DIM}No state available{RESET}")
|
|
986
|
+
else:
|
|
987
|
+
print(f"{DIM}Graph does not support state retrieval{RESET}")
|
|
988
|
+
except Exception as e:
|
|
989
|
+
print(f"{DIM}Could not retrieve history: {e}{RESET}")
|
|
990
|
+
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
@register_command(
|
|
995
|
+
name="reset",
|
|
996
|
+
description="Reset the session (clear history and restart)",
|
|
997
|
+
aliases=["restart"],
|
|
998
|
+
)
|
|
999
|
+
def cmd_reset(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
1000
|
+
"""Reset the session."""
|
|
1001
|
+
context["config"]["configurable"]["thread_id"] = str(uuid.uuid4())
|
|
1002
|
+
print(f"\n{GREEN}✓ Session reset{RESET}")
|
|
1003
|
+
print(f"{DIM}New thread ID: {context['config']['configurable']['thread_id'][:8]}...{RESET}\n")
|
|
1004
|
+
return None
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
@register_command(
|
|
1008
|
+
name="verbose",
|
|
1009
|
+
description="Toggle verbose output mode",
|
|
1010
|
+
usage="/verbose [on|off]",
|
|
1011
|
+
)
|
|
1012
|
+
def cmd_verbose(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
1013
|
+
"""Toggle or show verbose output mode."""
|
|
1014
|
+
verbose = context.get("verbose", False)
|
|
1015
|
+
if args:
|
|
1016
|
+
if args.lower() in ("on", "true", "1"):
|
|
1017
|
+
context["verbose"] = True
|
|
1018
|
+
print(f"{GREEN}✓ Verbose mode enabled{RESET}")
|
|
1019
|
+
elif args.lower() in ("off", "false", "0"):
|
|
1020
|
+
context["verbose"] = False
|
|
1021
|
+
print(f"{GREEN}✓ Verbose mode disabled{RESET}")
|
|
1022
|
+
else:
|
|
1023
|
+
print(f"{DIM}Verbose mode: {'on' if verbose else 'off'}{RESET}")
|
|
1024
|
+
print(f"{DIM}Use /verbose on or /verbose off to change{RESET}")
|
|
1025
|
+
return None
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def get_command_suggestions(partial: str) -> List[str]:
|
|
1029
|
+
"""Get command suggestions based on partial input.
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
partial: Partial command name (without leading /)
|
|
1033
|
+
|
|
1034
|
+
Returns:
|
|
1035
|
+
List of matching command names
|
|
1036
|
+
"""
|
|
1037
|
+
partial_lower = partial.lower()
|
|
1038
|
+
suggestions = []
|
|
1039
|
+
|
|
1040
|
+
for cmd in command_registry.all_commands():
|
|
1041
|
+
# Check main command name
|
|
1042
|
+
if cmd.name.startswith(partial_lower):
|
|
1043
|
+
suggestions.append(cmd.name)
|
|
1044
|
+
# Check aliases
|
|
1045
|
+
for alias in cmd.aliases:
|
|
1046
|
+
if alias.startswith(partial_lower) and cmd.name not in suggestions:
|
|
1047
|
+
suggestions.append(cmd.name)
|
|
1048
|
+
|
|
1049
|
+
return sorted(suggestions)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def command_completer(text: str, state: int) -> Optional[str]:
|
|
1053
|
+
"""Readline completer for slash commands.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
text: Current text being completed
|
|
1057
|
+
state: State index for multiple completions
|
|
1058
|
+
|
|
1059
|
+
Returns:
|
|
1060
|
+
Next completion or None
|
|
1061
|
+
"""
|
|
1062
|
+
# Only complete if starting with /
|
|
1063
|
+
if not text.startswith("/"):
|
|
1064
|
+
return None
|
|
1065
|
+
|
|
1066
|
+
partial = text[1:] # Remove leading /
|
|
1067
|
+
suggestions = ["/" + s for s in get_command_suggestions(partial)]
|
|
1068
|
+
|
|
1069
|
+
if state < len(suggestions):
|
|
1070
|
+
return suggestions[state]
|
|
1071
|
+
return None
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def setup_readline_completion():
|
|
1075
|
+
"""Set up readline for tab completion of slash commands."""
|
|
1076
|
+
if not HAS_READLINE:
|
|
1077
|
+
return
|
|
1078
|
+
|
|
1079
|
+
readline.set_completer(command_completer)
|
|
1080
|
+
readline.set_completer_delims(" \t\n")
|
|
1081
|
+
|
|
1082
|
+
# Use tab for completion
|
|
1083
|
+
if sys.platform == "darwin":
|
|
1084
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
1085
|
+
else:
|
|
1086
|
+
readline.parse_and_bind("tab: complete")
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def run_conversation_loop(
|
|
1090
|
+
graph,
|
|
1091
|
+
config: Dict[str, Any],
|
|
1092
|
+
agent_name: str = "AgentCode",
|
|
1093
|
+
use_async: bool = False,
|
|
1094
|
+
interactive: bool = True,
|
|
1095
|
+
verbose: bool = False,
|
|
1096
|
+
stream_mode: str = "updates",
|
|
1097
|
+
initial_message: Optional[str] = None,
|
|
1098
|
+
):
|
|
1099
|
+
"""
|
|
1100
|
+
Run a continuous conversation loop with the LangGraph agent.
|
|
1101
|
+
Styled after Claude Code / nanocode.
|
|
1102
|
+
"""
|
|
1103
|
+
# Set up tab completion for slash commands
|
|
1104
|
+
setup_readline_completion()
|
|
1105
|
+
|
|
1106
|
+
# Print box-drawn header with agent name
|
|
1107
|
+
print_header_box(agent_name, os.getcwd())
|
|
1108
|
+
|
|
1109
|
+
# Print welcome message with tips
|
|
1110
|
+
print_welcome()
|
|
1111
|
+
|
|
1112
|
+
# Create command context (mutable dict that commands can modify)
|
|
1113
|
+
command_context = {
|
|
1114
|
+
"graph": graph,
|
|
1115
|
+
"config": config,
|
|
1116
|
+
"agent_name": agent_name,
|
|
1117
|
+
"use_async": use_async,
|
|
1118
|
+
"interactive": interactive,
|
|
1119
|
+
"verbose": verbose,
|
|
1120
|
+
"stream_mode": stream_mode,
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
# Process initial message if provided
|
|
1124
|
+
if initial_message:
|
|
1125
|
+
print(f"\n{BOLD}{BRIGHT_BLUE}You{RESET}")
|
|
1126
|
+
print(f"{initial_message}")
|
|
1127
|
+
print()
|
|
1128
|
+
|
|
1129
|
+
if use_async:
|
|
1130
|
+
duration = asyncio.run(
|
|
1131
|
+
run_single_turn_async(graph, initial_message, config, interactive, verbose, stream_mode)
|
|
1132
|
+
)
|
|
1133
|
+
else:
|
|
1134
|
+
duration = run_single_turn_sync(graph, initial_message, config, interactive, verbose, stream_mode)
|
|
1135
|
+
print_timing(duration, verbose)
|
|
1136
|
+
print()
|
|
1137
|
+
|
|
1138
|
+
# Main conversation loop
|
|
1139
|
+
while True:
|
|
1140
|
+
try:
|
|
1141
|
+
print(separator("dots"))
|
|
1142
|
+
user_input = input(make_prompt()).strip()
|
|
1143
|
+
|
|
1144
|
+
if not user_input:
|
|
1145
|
+
continue
|
|
1146
|
+
|
|
1147
|
+
# Check if it's a slash command
|
|
1148
|
+
cmd_name, cmd_args = command_registry.parse_input(user_input)
|
|
1149
|
+
|
|
1150
|
+
if cmd_name is not None:
|
|
1151
|
+
# It's a slash command
|
|
1152
|
+
cmd = command_registry.get(cmd_name)
|
|
1153
|
+
if cmd:
|
|
1154
|
+
result = cmd.execute(cmd_args, command_context)
|
|
1155
|
+
# Update local vars from context (commands may modify these)
|
|
1156
|
+
verbose = command_context.get("verbose", verbose)
|
|
1157
|
+
if result == "exit":
|
|
1158
|
+
break
|
|
1159
|
+
else:
|
|
1160
|
+
# Show suggestions for unknown commands
|
|
1161
|
+
suggestions = get_command_suggestions(cmd_name)
|
|
1162
|
+
print(f"{YELLOW}Unknown command: /{cmd_name}{RESET}")
|
|
1163
|
+
if suggestions:
|
|
1164
|
+
suggestion_str = ", ".join([f"/{s}" for s in suggestions[:3]])
|
|
1165
|
+
print(f"{DIM}Did you mean: {suggestion_str}?{RESET}")
|
|
1166
|
+
else:
|
|
1167
|
+
print(f"{DIM}Type /help to see available commands{RESET}")
|
|
1168
|
+
continue
|
|
1169
|
+
|
|
1170
|
+
# Handle "exit" as a special case (without slash)
|
|
1171
|
+
if user_input.lower() == "exit":
|
|
1172
|
+
break
|
|
1173
|
+
|
|
1174
|
+
print() # Space before response
|
|
1175
|
+
|
|
1176
|
+
# Run the agent
|
|
1177
|
+
if use_async:
|
|
1178
|
+
duration = asyncio.run(
|
|
1179
|
+
run_single_turn_async(graph, user_input, config, interactive, verbose, stream_mode)
|
|
1180
|
+
)
|
|
1181
|
+
else:
|
|
1182
|
+
duration = run_single_turn_sync(graph, user_input, config, interactive, verbose, stream_mode)
|
|
1183
|
+
print_timing(duration, verbose)
|
|
1184
|
+
print()
|
|
1185
|
+
|
|
1186
|
+
except (EOFError, KeyboardInterrupt):
|
|
1187
|
+
break
|
|
1188
|
+
except Exception as err:
|
|
1189
|
+
print(f"\n{RED}✗ Error: {err}{RESET}\n")
|
|
1190
|
+
|
|
1191
|
+
# Print goodbye message
|
|
1192
|
+
print_goodbye()
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
@click.command()
|
|
1196
|
+
@click.argument("agent_spec", required=False)
|
|
1197
|
+
@click.option(
|
|
1198
|
+
"--graph-name",
|
|
1199
|
+
"-g",
|
|
1200
|
+
help="Name of the graph variable (default: 'graph', overridden if spec includes :name)",
|
|
1201
|
+
)
|
|
1202
|
+
@click.option(
|
|
1203
|
+
"--message",
|
|
1204
|
+
"-m",
|
|
1205
|
+
help="Input message to send to the agent",
|
|
1206
|
+
)
|
|
1207
|
+
@click.option(
|
|
1208
|
+
"--config",
|
|
1209
|
+
"-c",
|
|
1210
|
+
help="Configuration JSON string or path to JSON file",
|
|
1211
|
+
)
|
|
1212
|
+
@click.option(
|
|
1213
|
+
"--interactive/--no-interactive",
|
|
1214
|
+
default=True,
|
|
1215
|
+
help="Handle interrupts interactively (default: True)",
|
|
1216
|
+
)
|
|
1217
|
+
@click.option(
|
|
1218
|
+
"--async-mode/--sync-mode",
|
|
1219
|
+
"use_async",
|
|
1220
|
+
default=False,
|
|
1221
|
+
help="Use async streaming (default: sync)",
|
|
1222
|
+
)
|
|
1223
|
+
@click.option(
|
|
1224
|
+
"--stream-mode",
|
|
1225
|
+
help="Stream mode for LangGraph (default: 'updates')",
|
|
1226
|
+
)
|
|
1227
|
+
@click.option(
|
|
1228
|
+
"--verbose",
|
|
1229
|
+
"-v",
|
|
1230
|
+
is_flag=True,
|
|
1231
|
+
help="Show verbose output including node names",
|
|
1232
|
+
)
|
|
1233
|
+
def main(
|
|
1234
|
+
agent_spec: Optional[str],
|
|
1235
|
+
graph_name: Optional[str],
|
|
1236
|
+
message: Optional[str],
|
|
1237
|
+
config: Optional[str],
|
|
1238
|
+
interactive: bool,
|
|
1239
|
+
use_async: bool,
|
|
1240
|
+
stream_mode: Optional[str],
|
|
1241
|
+
verbose: bool,
|
|
1242
|
+
):
|
|
1243
|
+
"""
|
|
1244
|
+
Run a LangGraph agent from the command line.
|
|
1245
|
+
|
|
1246
|
+
AGENT_SPEC can be:
|
|
1247
|
+
\b
|
|
1248
|
+
- path/to/file.py (uses default graph name 'graph')
|
|
1249
|
+
- path/to/file.py:agent (specifies graph variable name)
|
|
1250
|
+
- package.module (Python module path)
|
|
1251
|
+
- package.module:agent (module with graph variable name)
|
|
1252
|
+
|
|
1253
|
+
Supports environment variables for configuration:
|
|
1254
|
+
|
|
1255
|
+
\b
|
|
1256
|
+
- DEEPAGENT_AGENT_SPEC: Agent location (same formats as above)
|
|
1257
|
+
- DEEPAGENT_WORKSPACE_ROOT: Working directory for the agent
|
|
1258
|
+
- DEEPAGENT_CONFIG: Configuration JSON string or path to JSON file
|
|
1259
|
+
- DEEPAGENT_STREAM_MODE: Stream mode for LangGraph (updates or values)
|
|
1260
|
+
|
|
1261
|
+
Command-line arguments override environment variables.
|
|
1262
|
+
|
|
1263
|
+
\b
|
|
1264
|
+
Examples:
|
|
1265
|
+
deepagent-code my_agent.py
|
|
1266
|
+
deepagent-code my_agent.py:graph
|
|
1267
|
+
deepagent-code mypackage.agents:chatbot
|
|
1268
|
+
deepagent-code -m "Hello, agent!"
|
|
1269
|
+
"""
|
|
1270
|
+
try:
|
|
1271
|
+
# Get environment variables
|
|
1272
|
+
env_agent_spec = os.getenv('DEEPAGENT_AGENT_SPEC')
|
|
1273
|
+
env_workspace_root = os.getenv('DEEPAGENT_WORKSPACE_ROOT')
|
|
1274
|
+
env_config = os.getenv('DEEPAGENT_CONFIG')
|
|
1275
|
+
env_stream_mode = os.getenv('DEEPAGENT_STREAM_MODE', 'updates')
|
|
1276
|
+
|
|
1277
|
+
# Determine which spec to use (CLI arg > env var > default)
|
|
1278
|
+
final_spec = agent_spec or env_agent_spec
|
|
1279
|
+
default_graph_name = graph_name or "graph"
|
|
1280
|
+
|
|
1281
|
+
# If no spec provided, try the default agent
|
|
1282
|
+
if not final_spec:
|
|
1283
|
+
default_agent_path = Path(__file__).parent.parent / "examples" / "agent.py"
|
|
1284
|
+
if default_agent_path.exists():
|
|
1285
|
+
final_spec = f"{default_agent_path}:agent"
|
|
1286
|
+
else:
|
|
1287
|
+
print(f"{RED}⏺ Error: No agent specified.{RESET}")
|
|
1288
|
+
print(f"\n{DIM}Usage:{RESET}")
|
|
1289
|
+
print(f" deepagent-code path/to/agent.py:graph")
|
|
1290
|
+
print(f" deepagent-code mypackage.module:agent")
|
|
1291
|
+
print(f"\n{DIM}Or set DEEPAGENT_AGENT_SPEC environment variable{RESET}")
|
|
1292
|
+
sys.exit(1)
|
|
1293
|
+
|
|
1294
|
+
# Change to workspace root if specified
|
|
1295
|
+
if env_workspace_root:
|
|
1296
|
+
workspace_path = Path(env_workspace_root).resolve()
|
|
1297
|
+
if workspace_path.exists():
|
|
1298
|
+
os.chdir(workspace_path)
|
|
1299
|
+
|
|
1300
|
+
# Load the graph with a spinner
|
|
1301
|
+
spinner = Spinner("Loading agent")
|
|
1302
|
+
spinner.start()
|
|
1303
|
+
graph, final_graph_name = load_graph(final_spec, default_graph_name)
|
|
1304
|
+
spinner.stop()
|
|
1305
|
+
print(f"{GREEN}✓{RESET} {DIM}Loaded {final_spec}{RESET}")
|
|
1306
|
+
|
|
1307
|
+
# Parse config
|
|
1308
|
+
config_dict = None
|
|
1309
|
+
config_source = config or env_config
|
|
1310
|
+
|
|
1311
|
+
if config_source:
|
|
1312
|
+
config_path = Path(config_source)
|
|
1313
|
+
if config_path.exists():
|
|
1314
|
+
with open(config_path) as f:
|
|
1315
|
+
config_dict = json.load(f)
|
|
1316
|
+
else:
|
|
1317
|
+
try:
|
|
1318
|
+
config_dict = json.loads(config_source)
|
|
1319
|
+
except json.JSONDecodeError as e:
|
|
1320
|
+
print(f"{RED}⏺ Invalid config JSON: {e}{RESET}")
|
|
1321
|
+
sys.exit(1)
|
|
1322
|
+
|
|
1323
|
+
# Get stream mode
|
|
1324
|
+
final_stream_mode = stream_mode or env_stream_mode
|
|
1325
|
+
|
|
1326
|
+
# Ensure config has a thread_id for checkpointer support
|
|
1327
|
+
if config_dict is None:
|
|
1328
|
+
config_dict = {}
|
|
1329
|
+
if "configurable" not in config_dict:
|
|
1330
|
+
config_dict["configurable"] = {}
|
|
1331
|
+
if "thread_id" not in config_dict["configurable"]:
|
|
1332
|
+
config_dict["configurable"]["thread_id"] = str(uuid.uuid4())
|
|
1333
|
+
|
|
1334
|
+
# Extract agent name from graph object
|
|
1335
|
+
agent_name = get_agent_name(graph)
|
|
1336
|
+
|
|
1337
|
+
# Run the conversation loop
|
|
1338
|
+
run_conversation_loop(
|
|
1339
|
+
graph=graph,
|
|
1340
|
+
config=config_dict,
|
|
1341
|
+
agent_name=agent_name,
|
|
1342
|
+
use_async=use_async,
|
|
1343
|
+
interactive=interactive,
|
|
1344
|
+
verbose=verbose,
|
|
1345
|
+
stream_mode=final_stream_mode,
|
|
1346
|
+
initial_message=message,
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
except FileNotFoundError as e:
|
|
1350
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
1351
|
+
sys.exit(1)
|
|
1352
|
+
except AttributeError as e:
|
|
1353
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
1354
|
+
sys.exit(1)
|
|
1355
|
+
except ModuleNotFoundError as e:
|
|
1356
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
1357
|
+
print(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
|
|
1358
|
+
sys.exit(1)
|
|
1359
|
+
except Exception as e:
|
|
1360
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
1361
|
+
if verbose:
|
|
1362
|
+
import traceback
|
|
1363
|
+
print(traceback.format_exc())
|
|
1364
|
+
sys.exit(1)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
if __name__ == "__main__":
|
|
1368
|
+
main()
|