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/utils.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reusable utilities for LangGraph agents.
|
|
3
|
+
|
|
4
|
+
This module provides generic functions for streaming from LangGraph agents,
|
|
5
|
+
handling interrupts, and processing various message types. It can be used
|
|
6
|
+
across different applications that use LangGraph.
|
|
7
|
+
"""
|
|
8
|
+
from typing import Any, Dict, Iterator, Optional, List, AsyncIterator
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import ast
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_interrupt_value(interrupt_value: Any) -> tuple[List[Any], List[Any]]:
|
|
15
|
+
"""
|
|
16
|
+
Parse interrupt value into action_requests and review_configs.
|
|
17
|
+
|
|
18
|
+
Handles different interrupt value formats from LangGraph:
|
|
19
|
+
- Tuple formats (single element, two elements)
|
|
20
|
+
- Object formats with attributes
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
interrupt_value: The interrupt value from LangGraph
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tuple of (action_requests, review_configs)
|
|
27
|
+
"""
|
|
28
|
+
action_requests = []
|
|
29
|
+
review_configs = []
|
|
30
|
+
|
|
31
|
+
if isinstance(interrupt_value, tuple):
|
|
32
|
+
if len(interrupt_value) == 1:
|
|
33
|
+
# Single-element tuple containing Interrupt object
|
|
34
|
+
interrupt_obj = interrupt_value[0]
|
|
35
|
+
if hasattr(interrupt_obj, 'value') and isinstance(interrupt_obj.value, dict):
|
|
36
|
+
action_requests = interrupt_obj.value.get('action_requests', [])
|
|
37
|
+
review_configs = interrupt_obj.value.get('review_configs', [])
|
|
38
|
+
else:
|
|
39
|
+
action_requests = getattr(interrupt_obj, 'action_requests', [])
|
|
40
|
+
review_configs = getattr(interrupt_obj, 'review_configs', [])
|
|
41
|
+
elif len(interrupt_value) == 2:
|
|
42
|
+
# Two-element tuple: (action_requests, review_configs)
|
|
43
|
+
action_requests, review_configs = interrupt_value
|
|
44
|
+
else:
|
|
45
|
+
# Handle object format
|
|
46
|
+
action_requests = getattr(interrupt_value, 'action_requests', [])
|
|
47
|
+
review_configs = getattr(interrupt_value, 'review_configs', [])
|
|
48
|
+
|
|
49
|
+
return action_requests, review_configs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def serialize_action_request(action: Any, index: int) -> Dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Serialize an action request to a dictionary.
|
|
55
|
+
|
|
56
|
+
Handles both dict and object formats, and both 'name' and 'tool' field names.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
action: The action request object or dict
|
|
60
|
+
index: The index of this action (used for fallback tool_call_id)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dictionary with tool, tool_call_id, args, and description
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(action, dict):
|
|
66
|
+
tool_name = action.get('tool') or action.get('name')
|
|
67
|
+
tool_call_id = action.get('tool_call_id', f"call_{index}")
|
|
68
|
+
args = action.get('args', {})
|
|
69
|
+
description = action.get('description')
|
|
70
|
+
else:
|
|
71
|
+
tool_name = getattr(action, 'tool', None) or getattr(action, 'name', None)
|
|
72
|
+
tool_call_id = getattr(action, 'tool_call_id', f"call_{index}")
|
|
73
|
+
args = getattr(action, 'args', {})
|
|
74
|
+
description = getattr(action, 'description', None)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"tool": tool_name,
|
|
78
|
+
"tool_call_id": tool_call_id,
|
|
79
|
+
"args": args,
|
|
80
|
+
"description": description
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def serialize_review_config(config: Any) -> Dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Serialize a review config to a dictionary.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
config: The review config object or dict
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dictionary with allowed_decisions
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(config, dict):
|
|
95
|
+
allowed_decisions = config.get('allowed_decisions', [])
|
|
96
|
+
else:
|
|
97
|
+
allowed_decisions = getattr(config, 'allowed_decisions', [])
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
"allowed_decisions": allowed_decisions
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
105
|
+
"""
|
|
106
|
+
Process a LangGraph interrupt value and convert to serializable format.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
interrupt_value: The interrupt value from the update
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Dictionary containing action_requests and review_configs
|
|
113
|
+
"""
|
|
114
|
+
action_requests, review_configs = parse_interrupt_value(interrupt_value)
|
|
115
|
+
|
|
116
|
+
interrupt_data = {
|
|
117
|
+
"action_requests": [],
|
|
118
|
+
"review_configs": []
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Extract action requests
|
|
122
|
+
for i, action in enumerate(action_requests):
|
|
123
|
+
interrupt_data["action_requests"].append(
|
|
124
|
+
serialize_action_request(action, i)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Extract review configs
|
|
128
|
+
for config in review_configs:
|
|
129
|
+
interrupt_data["review_configs"].append(
|
|
130
|
+
serialize_review_config(config)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return interrupt_data
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def extract_todos_from_content(tool_content: Any) -> Optional[List[Dict[str, Any]]]:
|
|
137
|
+
"""
|
|
138
|
+
Extract todos list from write_todos tool content.
|
|
139
|
+
|
|
140
|
+
Handles multiple formats:
|
|
141
|
+
- String with embedded JSON/list
|
|
142
|
+
- Dict with 'todos' key
|
|
143
|
+
- Direct list
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
tool_content: The content from the write_todos tool message
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of todo items or None if parsing fails
|
|
150
|
+
"""
|
|
151
|
+
todos = None
|
|
152
|
+
|
|
153
|
+
if isinstance(tool_content, str):
|
|
154
|
+
# Look for array pattern first (handles "Updated todo list to [...]" format)
|
|
155
|
+
match = re.search(r'\[.*\]', tool_content, re.DOTALL)
|
|
156
|
+
if match:
|
|
157
|
+
array_str = match.group(0)
|
|
158
|
+
|
|
159
|
+
# Try parsing as Python literal first (handles single quotes)
|
|
160
|
+
try:
|
|
161
|
+
todos = ast.literal_eval(array_str)
|
|
162
|
+
except:
|
|
163
|
+
# Fall back to JSON parsing (requires double quotes)
|
|
164
|
+
try:
|
|
165
|
+
todos = json.loads(array_str)
|
|
166
|
+
except:
|
|
167
|
+
pass
|
|
168
|
+
else:
|
|
169
|
+
# No array found, try parsing entire string as JSON
|
|
170
|
+
try:
|
|
171
|
+
parsed = json.loads(tool_content)
|
|
172
|
+
if isinstance(parsed, dict):
|
|
173
|
+
todos = parsed.get('todos')
|
|
174
|
+
# If todos is a string, parse it again
|
|
175
|
+
if isinstance(todos, str):
|
|
176
|
+
todos = json.loads(todos)
|
|
177
|
+
elif isinstance(parsed, list):
|
|
178
|
+
# Content is directly a list
|
|
179
|
+
todos = parsed
|
|
180
|
+
except:
|
|
181
|
+
pass
|
|
182
|
+
elif isinstance(tool_content, dict):
|
|
183
|
+
todos = tool_content.get('todos')
|
|
184
|
+
if isinstance(todos, str):
|
|
185
|
+
try:
|
|
186
|
+
todos = json.loads(todos)
|
|
187
|
+
except:
|
|
188
|
+
pass
|
|
189
|
+
elif isinstance(tool_content, list):
|
|
190
|
+
# Content is directly a list
|
|
191
|
+
todos = tool_content
|
|
192
|
+
|
|
193
|
+
return todos if isinstance(todos, list) else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def extract_reflection_from_content(tool_content: Any) -> Optional[str]:
|
|
197
|
+
"""
|
|
198
|
+
Extract reflection from think_tool content.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
tool_content: The content from the think_tool message
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Reflection string or None
|
|
205
|
+
"""
|
|
206
|
+
reflection = None
|
|
207
|
+
|
|
208
|
+
if isinstance(tool_content, str):
|
|
209
|
+
# Try to parse as JSON
|
|
210
|
+
try:
|
|
211
|
+
parsed = json.loads(tool_content)
|
|
212
|
+
reflection = parsed.get('reflection')
|
|
213
|
+
except:
|
|
214
|
+
reflection = tool_content
|
|
215
|
+
elif isinstance(tool_content, dict):
|
|
216
|
+
reflection = tool_content.get('reflection')
|
|
217
|
+
|
|
218
|
+
return reflection
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def serialize_tool_calls(tool_calls: List[Any], skip_tools: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
|
222
|
+
"""
|
|
223
|
+
Serialize tool calls to dictionaries, optionally skipping certain tools.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
tool_calls: List of tool call objects or dicts
|
|
227
|
+
skip_tools: Optional list of tool names to skip (e.g., ['think_tool', 'write_todos'])
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
List of serialized tool calls
|
|
231
|
+
"""
|
|
232
|
+
skip_tools = skip_tools or []
|
|
233
|
+
serialized = []
|
|
234
|
+
|
|
235
|
+
for tc in tool_calls:
|
|
236
|
+
tool_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, 'name', None)
|
|
237
|
+
|
|
238
|
+
# Skip specified tools
|
|
239
|
+
if tool_name in skip_tools:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
serialized.append({
|
|
243
|
+
"id": tc.get("id") if isinstance(tc, dict) else getattr(tc, 'id', None),
|
|
244
|
+
"name": tool_name,
|
|
245
|
+
"args": tc.get("args") if isinstance(tc, dict) else getattr(tc, 'args', {})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return serialized
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def clean_content_from_tool_dicts(content: str) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Remove tool call dictionary representations from content strings.
|
|
254
|
+
|
|
255
|
+
Tool calls often appear as strings like:
|
|
256
|
+
"{'id': '...', 'input': {...}, 'name': '...', 'type': 'tool_use'}"
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
content: The content string to clean
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Cleaned content string
|
|
263
|
+
"""
|
|
264
|
+
# Pattern to match tool call dictionary representations
|
|
265
|
+
tool_dict_pattern = r"\{'id':\s*'[^']+',\s*'input':\s*\{.*?\},\s*'name':\s*'[^']+',\s*'type':\s*'tool_use'\}"
|
|
266
|
+
content = re.sub(tool_dict_pattern, '', content, flags=re.DOTALL)
|
|
267
|
+
return content.strip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def process_message_content(message: Any) -> str:
|
|
271
|
+
"""
|
|
272
|
+
Extract and convert message content to string.
|
|
273
|
+
|
|
274
|
+
Handles different content formats:
|
|
275
|
+
- String content
|
|
276
|
+
- List of content blocks
|
|
277
|
+
- Other types (converted to string)
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
message: The message object
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Content as string
|
|
284
|
+
"""
|
|
285
|
+
if not hasattr(message, 'content'):
|
|
286
|
+
return ""
|
|
287
|
+
|
|
288
|
+
content = message.content
|
|
289
|
+
|
|
290
|
+
if isinstance(content, str):
|
|
291
|
+
return content
|
|
292
|
+
elif isinstance(content, list):
|
|
293
|
+
# Handle list of content blocks (e.g., [{"text": "...", "type": "text"}])
|
|
294
|
+
return " ".join(
|
|
295
|
+
block.get("text", str(block)) if isinstance(block, dict) else str(block)
|
|
296
|
+
for block in content
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
return str(content)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def process_tool_message(message: Any) -> Optional[Dict[str, Any]]:
|
|
303
|
+
"""
|
|
304
|
+
Process a ToolMessage and extract special content if applicable.
|
|
305
|
+
|
|
306
|
+
Handles special tools:
|
|
307
|
+
- think_tool: Extracts and returns reflection
|
|
308
|
+
- write_todos: Extracts and returns todo list
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
message: The ToolMessage to process
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Dictionary with chunk/todo_list and status, or None if no special handling
|
|
315
|
+
"""
|
|
316
|
+
if not hasattr(message, 'name'):
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
tool_name = message.name
|
|
320
|
+
tool_content = message.content
|
|
321
|
+
|
|
322
|
+
if tool_name == 'think_tool':
|
|
323
|
+
reflection = extract_reflection_from_content(tool_content)
|
|
324
|
+
if reflection:
|
|
325
|
+
return {
|
|
326
|
+
"chunk": reflection,
|
|
327
|
+
"status": "streaming"
|
|
328
|
+
}
|
|
329
|
+
elif tool_name == 'write_todos':
|
|
330
|
+
todos = extract_todos_from_content(tool_content)
|
|
331
|
+
if todos:
|
|
332
|
+
return {
|
|
333
|
+
"todo_list": todos,
|
|
334
|
+
"status": "streaming"
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def process_ai_message(message: Any, node_name: str, skip_tools: Optional[List[str]] = None) -> Iterator[Dict[str, Any]]:
|
|
341
|
+
"""
|
|
342
|
+
Process an AI message and yield content and tool calls.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
message: The AI message to process
|
|
346
|
+
node_name: Name of the graph node
|
|
347
|
+
skip_tools: Optional list of tool names to skip when serializing tool calls
|
|
348
|
+
|
|
349
|
+
Yields:
|
|
350
|
+
Dictionaries with tool_calls or chunk content
|
|
351
|
+
"""
|
|
352
|
+
skip_tools = skip_tools or ['think_tool', 'write_todos']
|
|
353
|
+
|
|
354
|
+
# Extract content
|
|
355
|
+
content_str = process_message_content(message)
|
|
356
|
+
|
|
357
|
+
# Check for tool calls
|
|
358
|
+
tool_calls = None
|
|
359
|
+
if hasattr(message, 'tool_calls') and message.tool_calls:
|
|
360
|
+
tool_calls = serialize_tool_calls(message.tool_calls, skip_tools=skip_tools)
|
|
361
|
+
|
|
362
|
+
# Clean content: strip whitespace and remove tool call dicts
|
|
363
|
+
content_str = content_str.strip() if content_str else ""
|
|
364
|
+
|
|
365
|
+
# Filter out tool call dictionaries from content
|
|
366
|
+
if content_str and hasattr(message, 'tool_calls') and message.tool_calls:
|
|
367
|
+
content_str = clean_content_from_tool_dicts(content_str)
|
|
368
|
+
|
|
369
|
+
# Yield tool calls (if any)
|
|
370
|
+
if tool_calls and len(tool_calls) > 0:
|
|
371
|
+
yield {
|
|
372
|
+
"tool_calls": tool_calls,
|
|
373
|
+
"node": node_name,
|
|
374
|
+
"status": "streaming"
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# Yield content separately, only if non-empty
|
|
378
|
+
if content_str:
|
|
379
|
+
yield {
|
|
380
|
+
"chunk": content_str,
|
|
381
|
+
"node": node_name,
|
|
382
|
+
"status": "streaming"
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def prepare_agent_input(
|
|
387
|
+
message: Optional[str] = None,
|
|
388
|
+
decisions: Optional[List[Dict[str, Any]]] = None,
|
|
389
|
+
raw_input: Optional[Any] = None
|
|
390
|
+
) -> Any:
|
|
391
|
+
"""
|
|
392
|
+
Prepare input for a LangGraph agent.
|
|
393
|
+
|
|
394
|
+
This function handles different input types:
|
|
395
|
+
- message: Regular user message (converted to message dict)
|
|
396
|
+
- decisions: Resume decisions (converted to Command)
|
|
397
|
+
- raw_input: Raw input passed directly (for custom formats)
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
message: Optional user message string
|
|
401
|
+
decisions: Optional list of interrupt decisions
|
|
402
|
+
raw_input: Optional raw input (bypasses message/decisions processing)
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Prepared input for the agent
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
ValueError: If no input is provided or multiple inputs are provided
|
|
409
|
+
"""
|
|
410
|
+
# Count how many inputs are provided
|
|
411
|
+
inputs_provided = sum([
|
|
412
|
+
message is not None,
|
|
413
|
+
decisions is not None,
|
|
414
|
+
raw_input is not None
|
|
415
|
+
])
|
|
416
|
+
|
|
417
|
+
if inputs_provided == 0:
|
|
418
|
+
raise ValueError("Must provide one of: message, decisions, or raw_input")
|
|
419
|
+
if inputs_provided > 1:
|
|
420
|
+
raise ValueError("Can only provide one of: message, decisions, or raw_input")
|
|
421
|
+
|
|
422
|
+
# Handle raw input (pass through)
|
|
423
|
+
if raw_input is not None:
|
|
424
|
+
return raw_input
|
|
425
|
+
|
|
426
|
+
# Handle regular message
|
|
427
|
+
if message is not None:
|
|
428
|
+
return {"messages": [{"role": "user", "content": message}]}
|
|
429
|
+
|
|
430
|
+
# Handle resume from interrupt
|
|
431
|
+
if decisions is not None:
|
|
432
|
+
from langgraph.types import Command
|
|
433
|
+
return Command(resume={"decisions": decisions})
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def stream_graph_updates(
|
|
437
|
+
agent,
|
|
438
|
+
input_data: Any,
|
|
439
|
+
config: Optional[Dict[str, Any]] = None,
|
|
440
|
+
stream_mode: str = "updates"
|
|
441
|
+
) -> Iterator[Dict[str, Any]]:
|
|
442
|
+
"""
|
|
443
|
+
Stream updates from a LangGraph agent.
|
|
444
|
+
|
|
445
|
+
This is a generic function that handles:
|
|
446
|
+
- Regular message streaming
|
|
447
|
+
- Interrupt detection and processing
|
|
448
|
+
- Special tool handling (think_tool, write_todos)
|
|
449
|
+
- Tool call serialization
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
agent: The LangGraph agent/graph instance
|
|
453
|
+
input_data: Input data for the agent (can be dict, Command, or any agent input)
|
|
454
|
+
config: Optional configuration for the agent
|
|
455
|
+
stream_mode: Stream mode for LangGraph (default: "updates")
|
|
456
|
+
|
|
457
|
+
Yields:
|
|
458
|
+
Dictionaries containing:
|
|
459
|
+
- {"chunk": str, "status": "streaming"} for text content
|
|
460
|
+
- {"tool_calls": list, "status": "streaming"} for tool calls
|
|
461
|
+
- {"todo_list": list, "status": "streaming"} for todos
|
|
462
|
+
- {"interrupt": dict, "status": "interrupt"} for interrupts
|
|
463
|
+
- {"status": "complete"} when finished
|
|
464
|
+
- {"error": str, "status": "error"} on errors
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
for update in agent.stream(input_data, config=config, stream_mode=stream_mode):
|
|
468
|
+
# Check for interrupts
|
|
469
|
+
if isinstance(update, dict) and "__interrupt__" in update:
|
|
470
|
+
interrupt_data = process_interrupt(update["__interrupt__"])
|
|
471
|
+
yield {
|
|
472
|
+
"interrupt": interrupt_data,
|
|
473
|
+
"status": "interrupt"
|
|
474
|
+
}
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
# Process regular updates
|
|
478
|
+
if isinstance(update, dict):
|
|
479
|
+
for node_name, state_data in update.items():
|
|
480
|
+
# Extract message content from the state update
|
|
481
|
+
if isinstance(state_data, dict) and "messages" in state_data:
|
|
482
|
+
messages = state_data["messages"]
|
|
483
|
+
if not messages:
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
# Get the last message in this update
|
|
487
|
+
last_message = messages[-1] if isinstance(messages, list) else messages
|
|
488
|
+
message_type = last_message.__class__.__name__ if hasattr(last_message, '__class__') else None
|
|
489
|
+
|
|
490
|
+
# Handle ToolMessage (tool outputs)
|
|
491
|
+
if message_type == 'ToolMessage':
|
|
492
|
+
result = process_tool_message(last_message)
|
|
493
|
+
if result:
|
|
494
|
+
yield result
|
|
495
|
+
|
|
496
|
+
# Handle regular messages (including AIMessage with tool calls)
|
|
497
|
+
elif hasattr(last_message, 'content'):
|
|
498
|
+
for chunk in process_ai_message(last_message, node_name):
|
|
499
|
+
yield chunk
|
|
500
|
+
|
|
501
|
+
yield {"status": "complete"}
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
yield {
|
|
505
|
+
"error": f"Error streaming from agent: {str(e)}",
|
|
506
|
+
"status": "error"
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def resume_graph_from_interrupt(
|
|
511
|
+
agent,
|
|
512
|
+
decisions: List[Dict[str, Any]],
|
|
513
|
+
config: Optional[Dict[str, Any]] = None,
|
|
514
|
+
stream_mode: str = "updates"
|
|
515
|
+
) -> Iterator[Dict[str, Any]]:
|
|
516
|
+
"""
|
|
517
|
+
Resume a LangGraph agent from an interrupt.
|
|
518
|
+
|
|
519
|
+
This is a convenience wrapper around stream_graph_updates that prepares
|
|
520
|
+
the resume input automatically.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
agent: The LangGraph agent/graph instance
|
|
524
|
+
decisions: List of decision objects with 'type' and optional fields
|
|
525
|
+
config: Optional configuration for the agent
|
|
526
|
+
stream_mode: Stream mode for LangGraph (default: "updates")
|
|
527
|
+
|
|
528
|
+
Yields:
|
|
529
|
+
Same format as stream_graph_updates
|
|
530
|
+
"""
|
|
531
|
+
try:
|
|
532
|
+
# Prepare resume input using the generic function
|
|
533
|
+
resume_input = prepare_agent_input(decisions=decisions)
|
|
534
|
+
|
|
535
|
+
# Use the same streaming logic as regular streaming
|
|
536
|
+
for chunk in stream_graph_updates(agent, resume_input, config=config, stream_mode=stream_mode):
|
|
537
|
+
yield chunk
|
|
538
|
+
|
|
539
|
+
except Exception as e:
|
|
540
|
+
yield {
|
|
541
|
+
"error": f"Error resuming from interrupt: {str(e)}",
|
|
542
|
+
"status": "error"
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ============================================================================
|
|
547
|
+
# ASYNC VARIANTS
|
|
548
|
+
# ============================================================================
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
async def astream_graph_updates(
|
|
552
|
+
agent,
|
|
553
|
+
input_data: Any,
|
|
554
|
+
config: Optional[Dict[str, Any]] = None,
|
|
555
|
+
stream_mode: str = "updates"
|
|
556
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
557
|
+
"""
|
|
558
|
+
Async version of stream_graph_updates.
|
|
559
|
+
|
|
560
|
+
Supports both stream_mode="updates" (granular) and "values" (simpler).
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
agent: The LangGraph agent/graph instance
|
|
564
|
+
input_data: Input data for the agent (can be dict, Command, or any agent input)
|
|
565
|
+
config: Optional configuration for the agent
|
|
566
|
+
stream_mode: Stream mode for LangGraph (default: "updates")
|
|
567
|
+
|
|
568
|
+
Yields:
|
|
569
|
+
Dictionaries containing:
|
|
570
|
+
- {"chunk": str, "status": "streaming"} for text content
|
|
571
|
+
- {"tool_calls": list, "status": "streaming"} for tool calls
|
|
572
|
+
- {"todo_list": list, "status": "streaming"} for todos
|
|
573
|
+
- {"interrupt": dict, "status": "interrupt"} for interrupts
|
|
574
|
+
- {"status": "complete"} when finished
|
|
575
|
+
- {"error": str, "status": "error"} on errors
|
|
576
|
+
"""
|
|
577
|
+
try:
|
|
578
|
+
async for update in agent.astream(input_data, config=config, stream_mode=stream_mode):
|
|
579
|
+
# Check for interrupts
|
|
580
|
+
if isinstance(update, dict) and "__interrupt__" in update:
|
|
581
|
+
interrupt_data = process_interrupt(update["__interrupt__"])
|
|
582
|
+
yield {
|
|
583
|
+
"interrupt": interrupt_data,
|
|
584
|
+
"status": "interrupt"
|
|
585
|
+
}
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
# Process regular updates
|
|
589
|
+
if isinstance(update, dict):
|
|
590
|
+
for node_name, state_data in update.items():
|
|
591
|
+
# Extract message content from the state update
|
|
592
|
+
if isinstance(state_data, dict) and "messages" in state_data:
|
|
593
|
+
messages = state_data["messages"]
|
|
594
|
+
if not messages:
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
# Get the last message in this update
|
|
598
|
+
last_message = messages[-1] if isinstance(messages, list) else messages
|
|
599
|
+
message_type = last_message.__class__.__name__ if hasattr(last_message, '__class__') else None
|
|
600
|
+
|
|
601
|
+
# Handle ToolMessage (tool outputs)
|
|
602
|
+
if message_type == 'ToolMessage':
|
|
603
|
+
result = process_tool_message(last_message)
|
|
604
|
+
if result:
|
|
605
|
+
yield result
|
|
606
|
+
|
|
607
|
+
# Handle regular messages (including AIMessage with tool calls)
|
|
608
|
+
elif hasattr(last_message, 'content'):
|
|
609
|
+
for chunk in process_ai_message(last_message, node_name):
|
|
610
|
+
yield chunk
|
|
611
|
+
|
|
612
|
+
yield {"status": "complete"}
|
|
613
|
+
|
|
614
|
+
except Exception as e:
|
|
615
|
+
yield {
|
|
616
|
+
"error": f"Error streaming from agent: {str(e)}",
|
|
617
|
+
"status": "error"
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def aresume_graph_from_interrupt(
|
|
622
|
+
agent,
|
|
623
|
+
decisions: List[Dict[str, Any]],
|
|
624
|
+
config: Optional[Dict[str, Any]] = None,
|
|
625
|
+
stream_mode: str = "updates"
|
|
626
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
627
|
+
"""
|
|
628
|
+
Async version of resume_graph_from_interrupt.
|
|
629
|
+
|
|
630
|
+
Resume a LangGraph agent from an interrupt asynchronously.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
agent: The LangGraph agent/graph instance
|
|
634
|
+
decisions: List of decision objects with 'type' and optional fields
|
|
635
|
+
config: Optional configuration for the agent
|
|
636
|
+
stream_mode: Stream mode for LangGraph (default: "updates")
|
|
637
|
+
|
|
638
|
+
Yields:
|
|
639
|
+
Same format as astream_graph_updates
|
|
640
|
+
"""
|
|
641
|
+
try:
|
|
642
|
+
# Prepare resume input using the generic function
|
|
643
|
+
resume_input = prepare_agent_input(decisions=decisions)
|
|
644
|
+
|
|
645
|
+
# Use the same streaming logic as regular async streaming
|
|
646
|
+
async for chunk in astream_graph_updates(agent, resume_input, config=config, stream_mode=stream_mode):
|
|
647
|
+
yield chunk
|
|
648
|
+
|
|
649
|
+
except Exception as e:
|
|
650
|
+
yield {
|
|
651
|
+
"error": f"Error resuming from interrupt: {str(e)}",
|
|
652
|
+
"status": "error"
|
|
653
|
+
}
|