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