deepagent-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagent_code/__init__.py +46 -0
- deepagent_code/cli.py +796 -0
- deepagent_code/utils.py +653 -0
- deepagent_code-0.1.0.dist-info/METADATA +161 -0
- deepagent_code-0.1.0.dist-info/RECORD +9 -0
- deepagent_code-0.1.0.dist-info/WHEEL +5 -0
- deepagent_code-0.1.0.dist-info/entry_points.txt +2 -0
- deepagent_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepagent_code-0.1.0.dist-info/top_level.txt +1 -0
deepagent_code/cli.py
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
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 termios
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import tty
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from deepagent_code.utils import (
|
|
22
|
+
prepare_agent_input,
|
|
23
|
+
stream_graph_updates,
|
|
24
|
+
astream_graph_updates,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ANSI color codes (matching nanocode style)
|
|
29
|
+
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
30
|
+
BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
|
|
31
|
+
|
|
32
|
+
# Spinner frames for thinking animation
|
|
33
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Spinner:
|
|
37
|
+
"""A simple terminal spinner for showing activity."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str = "Thinking"):
|
|
40
|
+
self.message = message
|
|
41
|
+
self.running = False
|
|
42
|
+
self.thread = None
|
|
43
|
+
self.frame_idx = 0
|
|
44
|
+
|
|
45
|
+
def _spin(self):
|
|
46
|
+
"""Run the spinner animation."""
|
|
47
|
+
while self.running:
|
|
48
|
+
frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
|
|
49
|
+
print(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}...{RESET}", end="", flush=True)
|
|
50
|
+
self.frame_idx += 1
|
|
51
|
+
time.sleep(0.08)
|
|
52
|
+
|
|
53
|
+
def start(self):
|
|
54
|
+
"""Start the spinner."""
|
|
55
|
+
self.running = True
|
|
56
|
+
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
57
|
+
self.thread.start()
|
|
58
|
+
|
|
59
|
+
def stop(self):
|
|
60
|
+
"""Stop the spinner and clear the line."""
|
|
61
|
+
self.running = False
|
|
62
|
+
if self.thread:
|
|
63
|
+
self.thread.join(timeout=0.2)
|
|
64
|
+
# Clear the spinner line
|
|
65
|
+
print("\r\033[2K", end="", flush=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def separator() -> str:
|
|
69
|
+
"""Return a dim separator line."""
|
|
70
|
+
try:
|
|
71
|
+
width = min(os.get_terminal_size().columns, 80)
|
|
72
|
+
except OSError:
|
|
73
|
+
width = 80
|
|
74
|
+
return f"{DIM}{'─' * width}{RESET}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_agent_name(graph) -> str:
|
|
78
|
+
"""Extract agent name from graph object, defaulting to 'AgentCode'."""
|
|
79
|
+
# Try common attribute names for agent/graph name
|
|
80
|
+
for attr in ('name', 'agent_name', '_name', '__name__'):
|
|
81
|
+
if hasattr(graph, attr):
|
|
82
|
+
name = getattr(graph, attr)
|
|
83
|
+
if name and isinstance(name, str):
|
|
84
|
+
return name
|
|
85
|
+
# Check if it's a compiled graph with a name in builder
|
|
86
|
+
if hasattr(graph, 'builder') and hasattr(graph.builder, 'name'):
|
|
87
|
+
name = graph.builder.name
|
|
88
|
+
if name and isinstance(name, str):
|
|
89
|
+
return name
|
|
90
|
+
return "AgentCode"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def print_header_box(agent_name: str, cwd: str):
|
|
94
|
+
"""Print a box-drawn header with the agent name."""
|
|
95
|
+
try:
|
|
96
|
+
term_width = min(os.get_terminal_size().columns, 80)
|
|
97
|
+
except OSError:
|
|
98
|
+
term_width = 80
|
|
99
|
+
|
|
100
|
+
# Box drawing characters
|
|
101
|
+
TL, TR, BL, BR = "╭", "╮", "╰", "╯" # corners
|
|
102
|
+
H, V = "─", "│" # horizontal and vertical
|
|
103
|
+
|
|
104
|
+
# Calculate inner width (accounting for borders and padding)
|
|
105
|
+
inner_width = term_width - 4 # 2 for borders, 2 for padding
|
|
106
|
+
|
|
107
|
+
# Build the header content
|
|
108
|
+
title_line = agent_name.center(inner_width)
|
|
109
|
+
cwd_display = cwd if len(cwd) <= inner_width else "..." + cwd[-(inner_width - 3):]
|
|
110
|
+
cwd_line = cwd_display.center(inner_width)
|
|
111
|
+
|
|
112
|
+
# Print the box
|
|
113
|
+
print(f"{CYAN}{TL}{H * (term_width - 2)}{TR}{RESET}")
|
|
114
|
+
print(f"{CYAN}{V}{RESET} {BOLD}{title_line}{RESET} {CYAN}{V}{RESET}")
|
|
115
|
+
print(f"{CYAN}{V}{RESET} {DIM}{cwd_line}{RESET} {CYAN}{V}{RESET}")
|
|
116
|
+
print(f"{CYAN}{BL}{H * (term_width - 2)}{BR}{RESET}")
|
|
117
|
+
print()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def render_markdown(text: str) -> str:
|
|
121
|
+
"""Simple markdown rendering for **bold** text."""
|
|
122
|
+
return re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_agent_spec(agent_spec: str) -> Tuple[str, str]:
|
|
126
|
+
"""
|
|
127
|
+
Parse DEEPAGENT_AGENT_SPEC format: path/to/file.py:variable_name.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
agent_spec: Agent specification string
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tuple of (file_path, variable_name)
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: If format is invalid
|
|
137
|
+
"""
|
|
138
|
+
if ':' not in agent_spec:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Invalid agent spec format: '{agent_spec}'. "
|
|
141
|
+
f"Expected format: 'path/to/file.py:variable_name'"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
parts = agent_spec.rsplit(':', 1)
|
|
145
|
+
file_path = parts[0]
|
|
146
|
+
variable_name = parts[1]
|
|
147
|
+
|
|
148
|
+
if not file_path.endswith('.py'):
|
|
149
|
+
raise ValueError(f"Agent spec file must be a .py file: {file_path}")
|
|
150
|
+
|
|
151
|
+
return file_path, variable_name
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_graph_from_file(file_path: str, graph_name: str = "graph"):
|
|
155
|
+
"""
|
|
156
|
+
Dynamically load a LangGraph graph from a Python file.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
file_path: Path to the Python file containing the graph
|
|
160
|
+
graph_name: Name of the graph variable (default: "graph")
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The loaded graph object
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
FileNotFoundError: If the file doesn't exist
|
|
167
|
+
AttributeError: If the graph variable doesn't exist in the module
|
|
168
|
+
Exception: For other loading errors
|
|
169
|
+
"""
|
|
170
|
+
file_path = Path(file_path).resolve()
|
|
171
|
+
|
|
172
|
+
if not file_path.exists():
|
|
173
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
174
|
+
|
|
175
|
+
# Load the module
|
|
176
|
+
spec = importlib.util.spec_from_file_location("graph_module", file_path)
|
|
177
|
+
if spec is None or spec.loader is None:
|
|
178
|
+
raise Exception(f"Could not load module from {file_path}")
|
|
179
|
+
|
|
180
|
+
module = importlib.util.module_from_spec(spec)
|
|
181
|
+
sys.modules["graph_module"] = module
|
|
182
|
+
spec.loader.exec_module(module)
|
|
183
|
+
|
|
184
|
+
# Get the graph object
|
|
185
|
+
if not hasattr(module, graph_name):
|
|
186
|
+
raise AttributeError(
|
|
187
|
+
f"Module does not have a '{graph_name}' variable. "
|
|
188
|
+
f"Available: {', '.join(dir(module))}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
graph = getattr(module, graph_name)
|
|
192
|
+
return graph
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_graph_from_module(module_path: str, graph_name: str = "graph"):
|
|
196
|
+
"""
|
|
197
|
+
Dynamically load a LangGraph graph from a Python module path.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
module_path: Dotted module path (e.g., "mypackage.agents.chatbot")
|
|
201
|
+
graph_name: Name of the graph variable (default: "graph")
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The loaded graph object
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ModuleNotFoundError: If the module doesn't exist
|
|
208
|
+
AttributeError: If the graph variable doesn't exist in the module
|
|
209
|
+
"""
|
|
210
|
+
import importlib
|
|
211
|
+
module = importlib.import_module(module_path)
|
|
212
|
+
|
|
213
|
+
if not hasattr(module, graph_name):
|
|
214
|
+
raise AttributeError(
|
|
215
|
+
f"Module '{module_path}' does not have a '{graph_name}' variable. "
|
|
216
|
+
f"Available: {', '.join(dir(module))}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
graph = getattr(module, graph_name)
|
|
220
|
+
return graph
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_graph(spec: str, default_graph_name: str = "graph"):
|
|
224
|
+
"""
|
|
225
|
+
Load a graph from either a file path or module path.
|
|
226
|
+
|
|
227
|
+
Supports formats:
|
|
228
|
+
- path/to/file.py (uses default_graph_name)
|
|
229
|
+
- path/to/file.py:graph_name
|
|
230
|
+
- package.module (uses default_graph_name)
|
|
231
|
+
- package.module:graph_name
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
spec: File path or module path, optionally with :graph_name suffix
|
|
235
|
+
default_graph_name: Graph name to use if not specified in spec
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The loaded graph object
|
|
239
|
+
"""
|
|
240
|
+
# Parse the spec to extract graph name if present
|
|
241
|
+
if ':' in spec:
|
|
242
|
+
path_or_module, graph_name = spec.rsplit(':', 1)
|
|
243
|
+
if not graph_name:
|
|
244
|
+
graph_name = default_graph_name
|
|
245
|
+
else:
|
|
246
|
+
path_or_module = spec
|
|
247
|
+
graph_name = default_graph_name
|
|
248
|
+
|
|
249
|
+
# Determine if it's a file path or module path
|
|
250
|
+
# File paths end with .py or contain path separators
|
|
251
|
+
is_file_path = (
|
|
252
|
+
path_or_module.endswith('.py') or
|
|
253
|
+
'/' in path_or_module or
|
|
254
|
+
'\\' in path_or_module or
|
|
255
|
+
Path(path_or_module).exists()
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if is_file_path:
|
|
259
|
+
return load_graph_from_file(path_or_module, graph_name), graph_name
|
|
260
|
+
else:
|
|
261
|
+
return load_graph_from_module(path_or_module, graph_name), graph_name
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_tool_arg_preview(args: Dict[str, Any]) -> str:
|
|
265
|
+
"""Get a preview of the first argument value (nanocode style)."""
|
|
266
|
+
if not args:
|
|
267
|
+
return ""
|
|
268
|
+
# Get first value
|
|
269
|
+
first_val = str(list(args.values())[0])
|
|
270
|
+
# Truncate if needed
|
|
271
|
+
if len(first_val) > 50:
|
|
272
|
+
return first_val[:50] + "..."
|
|
273
|
+
return first_val
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def format_result_preview(result: str) -> str:
|
|
277
|
+
"""Format a result preview with line count indicator."""
|
|
278
|
+
if not result:
|
|
279
|
+
return "(empty)"
|
|
280
|
+
lines = result.split("\n")
|
|
281
|
+
preview = lines[0][:60]
|
|
282
|
+
if len(lines) > 1:
|
|
283
|
+
preview += f" ... +{len(lines) - 1} lines"
|
|
284
|
+
elif len(lines[0]) > 60:
|
|
285
|
+
preview += "..."
|
|
286
|
+
return preview
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
|
|
290
|
+
"""
|
|
291
|
+
Pretty print a chunk from the stream using Claude Code styling.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
chunk: The chunk dictionary
|
|
295
|
+
verbose: Whether to show verbose output
|
|
296
|
+
"""
|
|
297
|
+
status = chunk.get("status")
|
|
298
|
+
|
|
299
|
+
if status == "streaming":
|
|
300
|
+
# Handle text chunks - cyan bullet with text
|
|
301
|
+
if "chunk" in chunk:
|
|
302
|
+
text = chunk["chunk"]
|
|
303
|
+
node = chunk.get("node", "unknown")
|
|
304
|
+
if verbose:
|
|
305
|
+
print(f"{DIM}[{node}]{RESET} {text}", end="")
|
|
306
|
+
else:
|
|
307
|
+
# Print text output with cyan bullet (only on first chunk or after newline)
|
|
308
|
+
print(f"{CYAN}⏺{RESET} {render_markdown(text)}", end="")
|
|
309
|
+
|
|
310
|
+
# Handle tool calls - green bullet with tool name
|
|
311
|
+
elif "tool_calls" in chunk:
|
|
312
|
+
for tool_call in chunk["tool_calls"]:
|
|
313
|
+
tool_name = tool_call["name"]
|
|
314
|
+
args = tool_call.get("args", {})
|
|
315
|
+
arg_preview = get_tool_arg_preview(args)
|
|
316
|
+
|
|
317
|
+
print(f"\n{GREEN}⏺ {tool_name.capitalize()}{RESET}({DIM}{arg_preview}{RESET})")
|
|
318
|
+
|
|
319
|
+
# Handle tool results - indented with result preview
|
|
320
|
+
elif "tool_result" in chunk:
|
|
321
|
+
result = chunk.get("tool_result", "")
|
|
322
|
+
preview = format_result_preview(str(result))
|
|
323
|
+
print(f" {DIM}⎿ {preview}{RESET}")
|
|
324
|
+
|
|
325
|
+
elif status == "interrupt":
|
|
326
|
+
interrupt_data = chunk.get("interrupt", {})
|
|
327
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
328
|
+
|
|
329
|
+
print(f"\n{YELLOW}⏺ Interrupt{RESET}")
|
|
330
|
+
if action_requests:
|
|
331
|
+
for i, action in enumerate(action_requests):
|
|
332
|
+
tool = action.get('tool', 'unknown')
|
|
333
|
+
args_preview = get_tool_arg_preview(action.get('args', {}))
|
|
334
|
+
print(f" {DIM}{i + 1}. {tool}({args_preview}){RESET}")
|
|
335
|
+
|
|
336
|
+
elif status == "complete":
|
|
337
|
+
pass # No output on complete (nanocode style)
|
|
338
|
+
|
|
339
|
+
elif status == "error":
|
|
340
|
+
error_msg = chunk.get("error", "Unknown error")
|
|
341
|
+
print(f"\n{RED}⏺ Error: {error_msg}{RESET}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_key() -> str:
|
|
345
|
+
"""Read a single keypress from stdin."""
|
|
346
|
+
fd = sys.stdin.fileno()
|
|
347
|
+
old_settings = termios.tcgetattr(fd)
|
|
348
|
+
try:
|
|
349
|
+
tty.setraw(fd)
|
|
350
|
+
ch = sys.stdin.read(1)
|
|
351
|
+
# Handle escape sequences (arrow keys)
|
|
352
|
+
if ch == '\x1b':
|
|
353
|
+
ch2 = sys.stdin.read(1)
|
|
354
|
+
if ch2 == '[':
|
|
355
|
+
ch3 = sys.stdin.read(1)
|
|
356
|
+
if ch3 == 'A':
|
|
357
|
+
return 'up'
|
|
358
|
+
elif ch3 == 'B':
|
|
359
|
+
return 'down'
|
|
360
|
+
elif ch == '\r' or ch == '\n':
|
|
361
|
+
return 'enter'
|
|
362
|
+
elif ch == '\x03': # Ctrl+C
|
|
363
|
+
return 'ctrl-c'
|
|
364
|
+
return ch
|
|
365
|
+
finally:
|
|
366
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def select_option(options: List[str], prompt: str = "Select an option:") -> int:
|
|
370
|
+
"""
|
|
371
|
+
Interactive option selector using arrow keys.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
options: List of option strings to display
|
|
375
|
+
prompt: Prompt to show above options
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Index of selected option (0-based)
|
|
379
|
+
"""
|
|
380
|
+
selected = 0
|
|
381
|
+
num_options = len(options)
|
|
382
|
+
|
|
383
|
+
# Hide cursor
|
|
384
|
+
print("\033[?25l", end="")
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
print(f"\n{BOLD}{prompt}{RESET}")
|
|
388
|
+
|
|
389
|
+
# Print initial options
|
|
390
|
+
for i, opt in enumerate(options):
|
|
391
|
+
if i == selected:
|
|
392
|
+
print(f" {CYAN}❯ {opt}{RESET}")
|
|
393
|
+
else:
|
|
394
|
+
print(f" {DIM}{opt}{RESET}")
|
|
395
|
+
|
|
396
|
+
while True:
|
|
397
|
+
key = get_key()
|
|
398
|
+
|
|
399
|
+
if key == 'up' and selected > 0:
|
|
400
|
+
selected -= 1
|
|
401
|
+
elif key == 'down' and selected < num_options - 1:
|
|
402
|
+
selected += 1
|
|
403
|
+
elif key == 'enter':
|
|
404
|
+
break
|
|
405
|
+
elif key == 'ctrl-c':
|
|
406
|
+
print("\033[?25h", end="") # Show cursor
|
|
407
|
+
sys.exit(0)
|
|
408
|
+
|
|
409
|
+
# Move cursor up to redraw options
|
|
410
|
+
print(f"\033[{num_options}A", end="")
|
|
411
|
+
|
|
412
|
+
# Redraw options
|
|
413
|
+
for i, opt in enumerate(options):
|
|
414
|
+
# Clear line and print option
|
|
415
|
+
print("\033[2K", end="") # Clear line
|
|
416
|
+
if i == selected:
|
|
417
|
+
print(f" {CYAN}❯ {opt}{RESET}")
|
|
418
|
+
else:
|
|
419
|
+
print(f" {DIM}{opt}{RESET}")
|
|
420
|
+
|
|
421
|
+
return selected
|
|
422
|
+
finally:
|
|
423
|
+
# Show cursor
|
|
424
|
+
print("\033[?25h", end="")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def handle_interrupt_input(num_actions: int = 1) -> List[Dict[str, Any]]:
|
|
428
|
+
"""
|
|
429
|
+
Handle user input for interrupt decisions using arrow key navigation.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
num_actions: Number of pending tool calls that need decisions
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of decision objects (one for each pending action)
|
|
436
|
+
"""
|
|
437
|
+
options = [
|
|
438
|
+
"Approve all actions",
|
|
439
|
+
"Reject all actions",
|
|
440
|
+
"Provide custom decision (JSON)",
|
|
441
|
+
"Exit",
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
choice = select_option(options, "How would you like to proceed?")
|
|
445
|
+
|
|
446
|
+
if choice == 0:
|
|
447
|
+
# Return approve decision for each pending action
|
|
448
|
+
return [{"type": "approve"} for _ in range(num_actions)]
|
|
449
|
+
elif choice == 1:
|
|
450
|
+
# Return reject decision for each pending action
|
|
451
|
+
return [{"type": "reject"} for _ in range(num_actions)]
|
|
452
|
+
elif choice == 2:
|
|
453
|
+
print("Enter your decision as JSON (will be applied to all actions):")
|
|
454
|
+
json_str = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
|
|
455
|
+
try:
|
|
456
|
+
decision = json.loads(json_str)
|
|
457
|
+
return [decision for _ in range(num_actions)]
|
|
458
|
+
except json.JSONDecodeError as e:
|
|
459
|
+
print(f"{RED}⏺ Invalid JSON: {e}{RESET}")
|
|
460
|
+
return [{"type": "reject"} for _ in range(num_actions)]
|
|
461
|
+
else:
|
|
462
|
+
sys.exit(0)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def run_single_turn_async(
|
|
466
|
+
graph,
|
|
467
|
+
message: str,
|
|
468
|
+
config: Optional[Dict[str, Any]] = None,
|
|
469
|
+
interactive: bool = True,
|
|
470
|
+
verbose: bool = False,
|
|
471
|
+
stream_mode: str = "updates",
|
|
472
|
+
):
|
|
473
|
+
"""Run a single turn of an async LangGraph graph."""
|
|
474
|
+
input_data = prepare_agent_input(message=message)
|
|
475
|
+
|
|
476
|
+
while True:
|
|
477
|
+
has_interrupt = False
|
|
478
|
+
num_pending_actions = 0
|
|
479
|
+
first_chunk = True
|
|
480
|
+
spinner = Spinner("Thinking")
|
|
481
|
+
spinner.start()
|
|
482
|
+
|
|
483
|
+
async for chunk in astream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
|
|
484
|
+
# Stop spinner on first chunk
|
|
485
|
+
if first_chunk:
|
|
486
|
+
spinner.stop()
|
|
487
|
+
first_chunk = False
|
|
488
|
+
|
|
489
|
+
print_chunk(chunk, verbose=verbose)
|
|
490
|
+
|
|
491
|
+
if chunk.get("status") == "interrupt":
|
|
492
|
+
has_interrupt = True
|
|
493
|
+
# Count pending action requests
|
|
494
|
+
interrupt_data = chunk.get("interrupt", {})
|
|
495
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
496
|
+
num_pending_actions = len(action_requests) if action_requests else 1
|
|
497
|
+
|
|
498
|
+
# Ensure spinner is stopped even if no chunks received
|
|
499
|
+
if first_chunk:
|
|
500
|
+
spinner.stop()
|
|
501
|
+
|
|
502
|
+
if has_interrupt and interactive:
|
|
503
|
+
decisions = handle_interrupt_input(num_pending_actions)
|
|
504
|
+
input_data = prepare_agent_input(decisions=decisions)
|
|
505
|
+
else:
|
|
506
|
+
break
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def run_single_turn_sync(
|
|
510
|
+
graph,
|
|
511
|
+
message: str,
|
|
512
|
+
config: Optional[Dict[str, Any]] = None,
|
|
513
|
+
interactive: bool = True,
|
|
514
|
+
verbose: bool = False,
|
|
515
|
+
stream_mode: str = "updates",
|
|
516
|
+
):
|
|
517
|
+
"""Run a single turn of a sync LangGraph graph."""
|
|
518
|
+
input_data = prepare_agent_input(message=message)
|
|
519
|
+
|
|
520
|
+
while True:
|
|
521
|
+
has_interrupt = False
|
|
522
|
+
num_pending_actions = 0
|
|
523
|
+
first_chunk = True
|
|
524
|
+
spinner = Spinner("Thinking")
|
|
525
|
+
spinner.start()
|
|
526
|
+
|
|
527
|
+
for chunk in stream_graph_updates(graph, input_data, config=config, stream_mode=stream_mode):
|
|
528
|
+
# Stop spinner on first chunk
|
|
529
|
+
if first_chunk:
|
|
530
|
+
spinner.stop()
|
|
531
|
+
first_chunk = False
|
|
532
|
+
|
|
533
|
+
print_chunk(chunk, verbose=verbose)
|
|
534
|
+
|
|
535
|
+
if chunk.get("status") == "interrupt":
|
|
536
|
+
has_interrupt = True
|
|
537
|
+
# Count pending action requests
|
|
538
|
+
interrupt_data = chunk.get("interrupt", {})
|
|
539
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
540
|
+
num_pending_actions = len(action_requests) if action_requests else 1
|
|
541
|
+
|
|
542
|
+
# Ensure spinner is stopped even if no chunks received
|
|
543
|
+
if first_chunk:
|
|
544
|
+
spinner.stop()
|
|
545
|
+
|
|
546
|
+
if has_interrupt and interactive:
|
|
547
|
+
decisions = handle_interrupt_input(num_pending_actions)
|
|
548
|
+
input_data = prepare_agent_input(decisions=decisions)
|
|
549
|
+
else:
|
|
550
|
+
break
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def run_conversation_loop(
|
|
554
|
+
graph,
|
|
555
|
+
config: Dict[str, Any],
|
|
556
|
+
agent_name: str = "AgentCode",
|
|
557
|
+
use_async: bool = False,
|
|
558
|
+
interactive: bool = True,
|
|
559
|
+
verbose: bool = False,
|
|
560
|
+
stream_mode: str = "updates",
|
|
561
|
+
initial_message: Optional[str] = None,
|
|
562
|
+
):
|
|
563
|
+
"""
|
|
564
|
+
Run a continuous conversation loop with the LangGraph agent.
|
|
565
|
+
Styled after Claude Code / nanocode.
|
|
566
|
+
"""
|
|
567
|
+
# Print box-drawn header with agent name
|
|
568
|
+
print_header_box(agent_name, os.getcwd())
|
|
569
|
+
|
|
570
|
+
# Process initial message if provided
|
|
571
|
+
if initial_message:
|
|
572
|
+
print(separator())
|
|
573
|
+
print(f"{BOLD}{BLUE}❯{RESET} {initial_message}")
|
|
574
|
+
print(separator())
|
|
575
|
+
|
|
576
|
+
if use_async:
|
|
577
|
+
asyncio.run(
|
|
578
|
+
run_single_turn_async(graph, initial_message, config, interactive, verbose, stream_mode)
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
run_single_turn_sync(graph, initial_message, config, interactive, verbose, stream_mode)
|
|
582
|
+
print()
|
|
583
|
+
|
|
584
|
+
# Main conversation loop
|
|
585
|
+
while True:
|
|
586
|
+
try:
|
|
587
|
+
print(separator())
|
|
588
|
+
user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
|
|
589
|
+
print(separator())
|
|
590
|
+
|
|
591
|
+
if not user_input:
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
# Handle special commands
|
|
595
|
+
if user_input in ("/q", "/quit", "/exit", "exit"):
|
|
596
|
+
break
|
|
597
|
+
|
|
598
|
+
if user_input == "/c":
|
|
599
|
+
# Generate new thread_id to start fresh conversation
|
|
600
|
+
config["configurable"]["thread_id"] = str(uuid.uuid4())
|
|
601
|
+
print(f"{GREEN}⏺ Cleared conversation{RESET}")
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
if user_input in ("/h", "/help"):
|
|
605
|
+
print(f"\n{BOLD}Commands:{RESET}")
|
|
606
|
+
print(f" /q, /quit, exit - Exit")
|
|
607
|
+
print(f" /c - Clear conversation")
|
|
608
|
+
print(f" /h, /help - Show this help\n")
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
# Run the agent
|
|
612
|
+
if use_async:
|
|
613
|
+
asyncio.run(
|
|
614
|
+
run_single_turn_async(graph, user_input, config, interactive, verbose, stream_mode)
|
|
615
|
+
)
|
|
616
|
+
else:
|
|
617
|
+
run_single_turn_sync(graph, user_input, config, interactive, verbose, stream_mode)
|
|
618
|
+
print()
|
|
619
|
+
|
|
620
|
+
except (EOFError, KeyboardInterrupt):
|
|
621
|
+
break
|
|
622
|
+
except Exception as err:
|
|
623
|
+
print(f"{RED}⏺ Error: {err}{RESET}")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@click.command()
|
|
627
|
+
@click.argument("agent_spec", required=False)
|
|
628
|
+
@click.option(
|
|
629
|
+
"--graph-name",
|
|
630
|
+
"-g",
|
|
631
|
+
help="Name of the graph variable (default: 'graph', overridden if spec includes :name)",
|
|
632
|
+
)
|
|
633
|
+
@click.option(
|
|
634
|
+
"--message",
|
|
635
|
+
"-m",
|
|
636
|
+
help="Input message to send to the agent",
|
|
637
|
+
)
|
|
638
|
+
@click.option(
|
|
639
|
+
"--config",
|
|
640
|
+
"-c",
|
|
641
|
+
help="Configuration JSON string or path to JSON file",
|
|
642
|
+
)
|
|
643
|
+
@click.option(
|
|
644
|
+
"--interactive/--no-interactive",
|
|
645
|
+
default=True,
|
|
646
|
+
help="Handle interrupts interactively (default: True)",
|
|
647
|
+
)
|
|
648
|
+
@click.option(
|
|
649
|
+
"--async-mode/--sync-mode",
|
|
650
|
+
"use_async",
|
|
651
|
+
default=False,
|
|
652
|
+
help="Use async streaming (default: sync)",
|
|
653
|
+
)
|
|
654
|
+
@click.option(
|
|
655
|
+
"--stream-mode",
|
|
656
|
+
help="Stream mode for LangGraph (default: 'updates')",
|
|
657
|
+
)
|
|
658
|
+
@click.option(
|
|
659
|
+
"--verbose",
|
|
660
|
+
"-v",
|
|
661
|
+
is_flag=True,
|
|
662
|
+
help="Show verbose output including node names",
|
|
663
|
+
)
|
|
664
|
+
def main(
|
|
665
|
+
agent_spec: Optional[str],
|
|
666
|
+
graph_name: Optional[str],
|
|
667
|
+
message: Optional[str],
|
|
668
|
+
config: Optional[str],
|
|
669
|
+
interactive: bool,
|
|
670
|
+
use_async: bool,
|
|
671
|
+
stream_mode: Optional[str],
|
|
672
|
+
verbose: bool,
|
|
673
|
+
):
|
|
674
|
+
"""
|
|
675
|
+
Run a LangGraph agent from the command line.
|
|
676
|
+
|
|
677
|
+
AGENT_SPEC can be:
|
|
678
|
+
\b
|
|
679
|
+
- path/to/file.py (uses default graph name 'graph')
|
|
680
|
+
- path/to/file.py:agent (specifies graph variable name)
|
|
681
|
+
- package.module (Python module path)
|
|
682
|
+
- package.module:agent (module with graph variable name)
|
|
683
|
+
|
|
684
|
+
Supports environment variables for configuration:
|
|
685
|
+
|
|
686
|
+
\b
|
|
687
|
+
- DEEPAGENT_AGENT_SPEC: Agent location (same formats as above)
|
|
688
|
+
- DEEPAGENT_WORKSPACE_ROOT: Working directory for the agent
|
|
689
|
+
- DEEPAGENT_CONFIG: Configuration JSON string or path to JSON file
|
|
690
|
+
- DEEPAGENT_STREAM_MODE: Stream mode for LangGraph (updates or values)
|
|
691
|
+
|
|
692
|
+
Command-line arguments override environment variables.
|
|
693
|
+
|
|
694
|
+
\b
|
|
695
|
+
Examples:
|
|
696
|
+
deepagent-code my_agent.py
|
|
697
|
+
deepagent-code my_agent.py:graph
|
|
698
|
+
deepagent-code mypackage.agents:chatbot
|
|
699
|
+
deepagent-code -m "Hello, agent!"
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
# Get environment variables
|
|
703
|
+
env_agent_spec = os.getenv('DEEPAGENT_AGENT_SPEC')
|
|
704
|
+
env_workspace_root = os.getenv('DEEPAGENT_WORKSPACE_ROOT')
|
|
705
|
+
env_config = os.getenv('DEEPAGENT_CONFIG')
|
|
706
|
+
env_stream_mode = os.getenv('DEEPAGENT_STREAM_MODE', 'updates')
|
|
707
|
+
|
|
708
|
+
# Determine which spec to use (CLI arg > env var > default)
|
|
709
|
+
final_spec = agent_spec or env_agent_spec
|
|
710
|
+
default_graph_name = graph_name or "graph"
|
|
711
|
+
|
|
712
|
+
# If no spec provided, try the default agent
|
|
713
|
+
if not final_spec:
|
|
714
|
+
default_agent_path = Path(__file__).parent.parent / "examples" / "agent.py"
|
|
715
|
+
if default_agent_path.exists():
|
|
716
|
+
final_spec = f"{default_agent_path}:agent"
|
|
717
|
+
else:
|
|
718
|
+
print(f"{RED}⏺ Error: No agent specified.{RESET}")
|
|
719
|
+
print(f"\n{DIM}Usage:{RESET}")
|
|
720
|
+
print(f" deepagent-code path/to/agent.py:graph")
|
|
721
|
+
print(f" deepagent-code mypackage.module:agent")
|
|
722
|
+
print(f"\n{DIM}Or set DEEPAGENT_AGENT_SPEC environment variable{RESET}")
|
|
723
|
+
sys.exit(1)
|
|
724
|
+
|
|
725
|
+
# Change to workspace root if specified
|
|
726
|
+
if env_workspace_root:
|
|
727
|
+
workspace_path = Path(env_workspace_root).resolve()
|
|
728
|
+
if workspace_path.exists():
|
|
729
|
+
os.chdir(workspace_path)
|
|
730
|
+
|
|
731
|
+
# Load the graph
|
|
732
|
+
print(f"{DIM}Loading {final_spec}...{RESET}")
|
|
733
|
+
graph, final_graph_name = load_graph(final_spec, default_graph_name)
|
|
734
|
+
|
|
735
|
+
# Parse config
|
|
736
|
+
config_dict = None
|
|
737
|
+
config_source = config or env_config
|
|
738
|
+
|
|
739
|
+
if config_source:
|
|
740
|
+
config_path = Path(config_source)
|
|
741
|
+
if config_path.exists():
|
|
742
|
+
with open(config_path) as f:
|
|
743
|
+
config_dict = json.load(f)
|
|
744
|
+
else:
|
|
745
|
+
try:
|
|
746
|
+
config_dict = json.loads(config_source)
|
|
747
|
+
except json.JSONDecodeError as e:
|
|
748
|
+
print(f"{RED}⏺ Invalid config JSON: {e}{RESET}")
|
|
749
|
+
sys.exit(1)
|
|
750
|
+
|
|
751
|
+
# Get stream mode
|
|
752
|
+
final_stream_mode = stream_mode or env_stream_mode
|
|
753
|
+
|
|
754
|
+
# Ensure config has a thread_id for checkpointer support
|
|
755
|
+
if config_dict is None:
|
|
756
|
+
config_dict = {}
|
|
757
|
+
if "configurable" not in config_dict:
|
|
758
|
+
config_dict["configurable"] = {}
|
|
759
|
+
if "thread_id" not in config_dict["configurable"]:
|
|
760
|
+
config_dict["configurable"]["thread_id"] = str(uuid.uuid4())
|
|
761
|
+
|
|
762
|
+
# Extract agent name from graph object
|
|
763
|
+
agent_name = get_agent_name(graph)
|
|
764
|
+
|
|
765
|
+
# Run the conversation loop
|
|
766
|
+
run_conversation_loop(
|
|
767
|
+
graph=graph,
|
|
768
|
+
config=config_dict,
|
|
769
|
+
agent_name=agent_name,
|
|
770
|
+
use_async=use_async,
|
|
771
|
+
interactive=interactive,
|
|
772
|
+
verbose=verbose,
|
|
773
|
+
stream_mode=final_stream_mode,
|
|
774
|
+
initial_message=message,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
except FileNotFoundError as e:
|
|
778
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
779
|
+
sys.exit(1)
|
|
780
|
+
except AttributeError as e:
|
|
781
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
782
|
+
sys.exit(1)
|
|
783
|
+
except ModuleNotFoundError as e:
|
|
784
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
785
|
+
print(f"\n{DIM}Make sure your agent's dependencies are installed.{RESET}")
|
|
786
|
+
sys.exit(1)
|
|
787
|
+
except Exception as e:
|
|
788
|
+
print(f"{RED}⏺ Error: {e}{RESET}")
|
|
789
|
+
if verbose:
|
|
790
|
+
import traceback
|
|
791
|
+
print(traceback.format_exc())
|
|
792
|
+
sys.exit(1)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
if __name__ == "__main__":
|
|
796
|
+
main()
|