hammad-python 0.0.23__py3-none-any.whl → 0.0.25__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.
Files changed (40) hide show
  1. hammad/__init__.py +62 -14
  2. hammad/_main.py +226 -0
  3. hammad/cli/__init__.py +0 -2
  4. hammad/cli/plugins.py +3 -1
  5. hammad/data/__init__.py +4 -5
  6. hammad/data/types/__init__.py +37 -1
  7. hammad/data/types/file.py +74 -1
  8. hammad/data/types/multimodal/__init__.py +14 -2
  9. hammad/data/types/multimodal/audio.py +106 -2
  10. hammad/data/types/multimodal/image.py +104 -2
  11. hammad/data/types/text.py +242 -0
  12. hammad/genai/__init__.py +73 -0
  13. hammad/genai/a2a/__init__.py +32 -0
  14. hammad/genai/a2a/workers.py +552 -0
  15. hammad/genai/agents/__init__.py +8 -0
  16. hammad/genai/agents/agent.py +747 -214
  17. hammad/genai/agents/run.py +421 -12
  18. hammad/genai/agents/types/agent_response.py +2 -1
  19. hammad/genai/graphs/__init__.py +125 -0
  20. hammad/genai/graphs/base.py +1786 -0
  21. hammad/genai/graphs/plugins.py +316 -0
  22. hammad/genai/graphs/types.py +638 -0
  23. hammad/genai/models/language/__init__.py +6 -1
  24. hammad/genai/models/language/model.py +46 -0
  25. hammad/genai/models/language/run.py +330 -4
  26. hammad/genai/models/language/types/language_model_response.py +1 -1
  27. hammad/genai/types/tools.py +1 -1
  28. hammad/logging/logger.py +60 -5
  29. hammad/mcp/__init__.py +3 -0
  30. hammad/types.py +288 -0
  31. {hammad_python-0.0.23.dist-info → hammad_python-0.0.25.dist-info}/METADATA +6 -1
  32. {hammad_python-0.0.23.dist-info → hammad_python-0.0.25.dist-info}/RECORD +34 -32
  33. hammad/_main/__init__.py +0 -4
  34. hammad/_main/_fn.py +0 -20
  35. hammad/_main/_new.py +0 -52
  36. hammad/_main/_run.py +0 -50
  37. hammad/_main/_to.py +0 -19
  38. hammad/cli/_runner.py +0 -265
  39. {hammad_python-0.0.23.dist-info → hammad_python-0.0.25.dist-info}/WHEEL +0 -0
  40. {hammad_python-0.0.23.dist-info → hammad_python-0.0.25.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1786 @@
1
+ """hammad.genai.graphs.base - Graph implementation using pydantic-graph with Agent/LanguageModel integration"""
2
+
3
+ from typing import (
4
+ Any,
5
+ Dict,
6
+ List,
7
+ Optional,
8
+ Type,
9
+ TypeVar,
10
+ Generic,
11
+ Union,
12
+ Callable,
13
+ get_type_hints,
14
+ ParamSpec,
15
+ TypeAlias,
16
+ Awaitable,
17
+ TYPE_CHECKING,
18
+ )
19
+ from typing_extensions import Literal
20
+ from dataclasses import dataclass, field
21
+ import inspect
22
+ from functools import wraps
23
+ import asyncio
24
+
25
+ from pydantic_graph import BaseNode, End, Graph as PydanticGraph, GraphRunContext
26
+ from ..models.language.utils import (
27
+ parse_messages_input,
28
+ consolidate_system_messages,
29
+ )
30
+ from ...formatting.text import convert_to_text
31
+
32
+ from ..agents.agent import Agent
33
+ from ..agents.types.agent_response import AgentResponse
34
+ from ..agents.types.agent_messages import AgentMessages
35
+ from ..models.language.model import LanguageModel
36
+ from ..models.language.types.language_model_name import LanguageModelName
37
+ from .types import (
38
+ GraphContext,
39
+ GraphResponse,
40
+ GraphStream,
41
+ GraphResponseChunk,
42
+ GraphState,
43
+ BasePlugin,
44
+ ActionSettings,
45
+ GraphHistoryEntry,
46
+ )
47
+
48
+ if TYPE_CHECKING:
49
+ try:
50
+ from fasta2a import FastA2A
51
+ except ImportError:
52
+ FastA2A: TypeAlias = Any
53
+
54
+ __all__ = [
55
+ "BaseGraph",
56
+ "action",
57
+ "ActionNode",
58
+ "GraphBuilder",
59
+ "GraphStream",
60
+ "GraphResponseChunk",
61
+ "select",
62
+ "SelectionStrategy",
63
+ ]
64
+
65
+ T = TypeVar("T")
66
+ StateT = TypeVar("StateT")
67
+ P = ParamSpec("P")
68
+
69
+
70
+ class SelectionStrategy:
71
+ """LLM-based selection strategy for choosing the next action."""
72
+
73
+ def __init__(
74
+ self,
75
+ *actions: str,
76
+ instructions: Optional[str] = None,
77
+ model: Optional[str] = None,
78
+ ):
79
+ self.actions = list(actions)
80
+ self.instructions = instructions
81
+ self.model = model or "openai/gpt-4o-mini"
82
+ self._language_model = None
83
+ self._use_all_actions = (
84
+ len(actions) == 0
85
+ ) # If no actions specified, use all available
86
+
87
+ def _get_language_model(self):
88
+ """Lazy load the language model."""
89
+ if self._language_model is None:
90
+ from ..models.language.model import LanguageModel
91
+
92
+ self._language_model = LanguageModel(model=self.model)
93
+ return self._language_model
94
+
95
+ def select(self, context: Optional[Dict[str, Any]] = None) -> str:
96
+ """Use LLM to select the most appropriate action."""
97
+ if not context:
98
+ context = {}
99
+
100
+ # Get available actions
101
+ actions_to_choose_from = self.actions
102
+ if self._use_all_actions and "all_actions" in context:
103
+ # Use all available actions from the graph
104
+ actions_to_choose_from = context["all_actions"]
105
+
106
+ if not actions_to_choose_from:
107
+ return ""
108
+
109
+ # If only one action, return it
110
+ if len(actions_to_choose_from) == 1:
111
+ return actions_to_choose_from[0]
112
+
113
+ # Import here to avoid circular imports
114
+ from pydantic import BaseModel, Field, create_model
115
+ from enum import Enum
116
+
117
+ # Create enum for available actions
118
+ ActionEnum = Enum(
119
+ "ActionEnum", {action: action for action in actions_to_choose_from}
120
+ )
121
+
122
+ # Create selection model
123
+ SelectionModel = create_model(
124
+ "ActionSelection",
125
+ action=(
126
+ ActionEnum,
127
+ Field(description="The selected action to execute next"),
128
+ ),
129
+ reasoning=(str, Field(description="Brief reasoning for the selection")),
130
+ )
131
+
132
+ # Build context description
133
+ context_parts = []
134
+
135
+ # Add result from previous action
136
+ if "result" in context:
137
+ context_parts.append(f"Previous action result: {context['result']}")
138
+
139
+ # Add conversation history
140
+ if "messages" in context and context["messages"]:
141
+ # Get last few messages for context
142
+ recent_messages = context["messages"][-5:] # Last 5 messages
143
+ messages_str = "\n".join(
144
+ [
145
+ f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
146
+ for msg in recent_messages
147
+ ]
148
+ )
149
+ context_parts.append(f"Recent conversation:\n{messages_str}")
150
+
151
+ # Add state information
152
+ if "state" in context and context["state"]:
153
+ context_parts.append(f"Current state: {context['state']}")
154
+
155
+ context_description = "\n\n".join(context_parts)
156
+
157
+ # Build selection prompt
158
+ base_instructions = f"""Based on the context below, select the most appropriate next action from the available options.
159
+
160
+ Available actions:
161
+ {", ".join(actions_to_choose_from)}
162
+
163
+ Context:
164
+ {context_description}
165
+
166
+ Consider the conversation flow, user's request, and any patterns in the conversation when making your selection.
167
+ For example, if the user asked to do something multiple times (e.g., "reason twice"), and you've only done it once, select that action again."""
168
+
169
+ # Add custom instructions if provided
170
+ if self.instructions:
171
+ base_instructions = (
172
+ f"{base_instructions}\n\nAdditional instructions:\n{self.instructions}"
173
+ )
174
+
175
+ # Get language model to make selection
176
+ try:
177
+ lm = self._get_language_model()
178
+ response = lm.run(
179
+ messages=[{"role": "user", "content": base_instructions}],
180
+ type=SelectionModel,
181
+ )
182
+
183
+ selected_action = response.output.action.value
184
+
185
+ # Validate the selection
186
+ if selected_action in actions_to_choose_from:
187
+ return selected_action
188
+ else:
189
+ # Fallback to first action if invalid selection
190
+ return actions_to_choose_from[0]
191
+
192
+ except Exception:
193
+ # Fallback to first action on any error
194
+ return actions_to_choose_from[0] if actions_to_choose_from else ""
195
+
196
+ def __repr__(self) -> str:
197
+ if self._use_all_actions:
198
+ return f"SelectionStrategy(all_actions)"
199
+ return f"SelectionStrategy({', '.join(repr(a) for a in self.actions)})"
200
+
201
+ def select(self, context: Optional[Dict[str, Any]] = None) -> str:
202
+ """Use LLM to select the most appropriate action."""
203
+ if not context or not self.actions:
204
+ return self.actions[0] if self.actions else ""
205
+
206
+ # Import here to avoid circular imports
207
+ from pydantic import BaseModel, Field, create_model
208
+ from enum import Enum
209
+
210
+ # Create enum for available actions
211
+ ActionEnum = Enum("ActionEnum", {action: action for action in self.actions})
212
+
213
+ # Create selection model
214
+ SelectionModel = create_model(
215
+ "ActionSelection",
216
+ action=(
217
+ ActionEnum,
218
+ Field(description="The selected action to execute next"),
219
+ ),
220
+ reasoning=(str, Field(description="Brief reasoning for the selection")),
221
+ )
222
+
223
+ # Build context description
224
+ context_parts = []
225
+
226
+ # Add result from previous action
227
+ if "result" in context:
228
+ context_parts.append(f"Previous action result: {context['result']}")
229
+
230
+ # Add conversation history
231
+ if "messages" in context and context["messages"]:
232
+ # Get last few messages for context
233
+ recent_messages = context["messages"][-5:] # Last 5 messages
234
+ messages_str = "\n".join(
235
+ [
236
+ f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
237
+ for msg in recent_messages
238
+ ]
239
+ )
240
+ context_parts.append(f"Recent conversation:\n{messages_str}")
241
+
242
+ # Add state information
243
+ if "state" in context and context["state"]:
244
+ context_parts.append(f"Current state: {context['state']}")
245
+
246
+ context_description = "\n\n".join(context_parts)
247
+
248
+ # Build selection prompt
249
+ base_instructions = f"""Based on the context below, select the most appropriate next action from the available options.
250
+
251
+ Available actions:
252
+ {", ".join(self.actions)}
253
+
254
+ Context:
255
+ {context_description}
256
+
257
+ Consider the conversation flow and any specific instructions from the user when making your selection."""
258
+
259
+ # Add custom instructions if provided
260
+ if self.instructions:
261
+ base_instructions = (
262
+ f"{base_instructions}\n\nAdditional instructions:\n{self.instructions}"
263
+ )
264
+
265
+ # Get language model to make selection
266
+ try:
267
+ lm = self._get_language_model()
268
+ response = lm.run(
269
+ messages=[{"role": "user", "content": base_instructions}],
270
+ type=SelectionModel,
271
+ )
272
+
273
+ selected_action = response.output.action.value
274
+
275
+ # Validate the selection
276
+ if selected_action in self.actions:
277
+ return selected_action
278
+ else:
279
+ # Fallback to first action if invalid selection
280
+ return self.actions[0]
281
+
282
+ except Exception:
283
+ # Fallback to first action on any error
284
+ return self.actions[0] if self.actions else ""
285
+
286
+
287
+ def select(
288
+ *actions: str, instructions: Optional[str] = None, model: Optional[str] = None
289
+ ) -> SelectionStrategy:
290
+ """
291
+ Create an LLM-based selection strategy for choosing between multiple actions.
292
+
293
+ Args:
294
+ *actions: The action names to choose from. If empty, will select from all available actions.
295
+ instructions: Optional instructions for the LLM selection
296
+ model: Optional model to use for selection (defaults to gpt-4o-mini)
297
+
298
+ Returns:
299
+ A SelectionStrategy instance
300
+
301
+ Examples:
302
+ # Select between specific actions
303
+ @action(next=select("poem", "response"))
304
+ def reasoning(self, message: str) -> str:
305
+ ...
306
+
307
+ # Select from all available actions in the graph
308
+ @action(next=select())
309
+ def reasoning(self, message: str) -> str:
310
+ ...
311
+
312
+ # With custom instructions
313
+ @action(next=select("reasoning", "response",
314
+ instructions="If the user asked for multiple reasonings, select 'reasoning' again"))
315
+ def reasoning(self, message: str) -> str:
316
+ ...
317
+ """
318
+ return SelectionStrategy(*actions, instructions=instructions, model=model)
319
+
320
+
321
+ class ActionNode(BaseNode[StateT, None, Any]):
322
+ """A pydantic-graph node that wraps a user-defined action function."""
323
+
324
+ def __init__(
325
+ self,
326
+ action_name: str,
327
+ action_func: Callable,
328
+ settings: ActionSettings,
329
+ **action_params: Any,
330
+ ):
331
+ """Initialize the action node with parameters."""
332
+ self.action_name = action_name
333
+ self.action_func = action_func
334
+ self.settings = settings
335
+
336
+ # Store action parameters as instance attributes for pydantic-graph
337
+ for param_name, param_value in action_params.items():
338
+ setattr(self, param_name, param_value)
339
+
340
+ async def run(self, ctx: GraphRunContext[StateT]) -> Union[BaseNode, End]:
341
+ """Execute the action function using Agent/LanguageModel infrastructure."""
342
+
343
+ # Track this node's execution
344
+ execution_tracker = getattr(self, "_execution_tracker", [])
345
+ execution_tracker.append(self.action_name)
346
+
347
+ # Create enhanced context that wraps pydantic-graph context
348
+ enhanced_ctx = GraphContext(
349
+ pydantic_context=ctx,
350
+ plugins=[], # Will be populated by BaseGraph
351
+ history=[],
352
+ metadata={},
353
+ )
354
+
355
+ # Extract action parameters from self
356
+ action_params = {}
357
+ sig = inspect.signature(self.action_func)
358
+ for param_name in sig.parameters:
359
+ if param_name not in ("self", "ctx", "context", "agent", "language_model"):
360
+ if hasattr(self, param_name):
361
+ action_params[param_name] = getattr(self, param_name)
362
+
363
+ # Get the docstring from the action function to use as field-level instructions
364
+ field_instructions = self.action_func.__doc__ or ""
365
+
366
+ # Get the global system prompt from the graph class docstring
367
+ global_system_prompt = ""
368
+ if hasattr(self, "_graph_docstring"):
369
+ global_system_prompt = self._graph_docstring
370
+
371
+ # Get state from the context if available
372
+ current_state = None
373
+ if hasattr(ctx, "state") and ctx.state is not None:
374
+ current_state = ctx.state
375
+ elif hasattr(self, "_state"):
376
+ current_state = getattr(self, "_state", None)
377
+
378
+ # Check if the action function expects to handle the language model itself
379
+ expects_language_model = (
380
+ "language_model" in sig.parameters or "agent" in sig.parameters
381
+ )
382
+
383
+ if expects_language_model:
384
+ # Legacy mode: action function expects to handle language model
385
+ # Combine global system prompt with field-level instructions and state
386
+ combined_instructions = global_system_prompt
387
+ if field_instructions and field_instructions not in combined_instructions:
388
+ if combined_instructions:
389
+ combined_instructions += f"\n\n{field_instructions}"
390
+ else:
391
+ combined_instructions = field_instructions
392
+
393
+ # Add state to instructions if available
394
+ if current_state is not None:
395
+ state_str = convert_to_text(current_state, show_defaults=False)
396
+ if state_str:
397
+ combined_instructions += f"\n\nState: {state_str}"
398
+
399
+ # Get verbose/debug flags and language model kwargs from the node
400
+ verbose = getattr(self, "_verbose", self.settings.verbose)
401
+ debug = getattr(self, "_debug", self.settings.debug)
402
+ language_model_kwargs = getattr(self, "_language_model_kwargs", {})
403
+
404
+ # Get end strategy parameters from node or settings
405
+ max_steps = getattr(self, "_max_steps", self.settings.max_steps)
406
+ end_strategy = getattr(self, "_end_strategy", self.settings.end_strategy)
407
+ end_tool = getattr(self, "_end_tool", self.settings.end_tool)
408
+
409
+ if self.settings.tools or self.settings.instructions:
410
+ # Get model from settings, then language_model_kwargs, then default
411
+ model = self.settings.model or language_model_kwargs.get(
412
+ "model", "openai/gpt-4o-mini"
413
+ )
414
+
415
+ # Remove parameters that will be passed explicitly to avoid duplicates
416
+ filtered_kwargs = {
417
+ k: v
418
+ for k, v in language_model_kwargs.items()
419
+ if k
420
+ not in [
421
+ "model",
422
+ "name",
423
+ "instructions",
424
+ "tools",
425
+ "max_steps",
426
+ "end_strategy",
427
+ "end_tool",
428
+ "verbose",
429
+ "debug",
430
+ ]
431
+ }
432
+
433
+ agent = Agent(
434
+ name=self.settings.name or self.action_name,
435
+ instructions=self.settings.instructions or combined_instructions,
436
+ model=model,
437
+ tools=self.settings.tools,
438
+ max_steps=max_steps,
439
+ end_strategy=end_strategy,
440
+ end_tool=end_tool,
441
+ verbose=verbose,
442
+ debug=debug,
443
+ **filtered_kwargs,
444
+ )
445
+ # Pass history to context if available
446
+ history = getattr(self, "_history", None)
447
+ if history:
448
+ enhanced_ctx.metadata["history"] = history
449
+
450
+ if asyncio.iscoroutinefunction(self.action_func):
451
+ result = await self.action_func(
452
+ enhanced_ctx, agent, **action_params
453
+ )
454
+ else:
455
+ result = self.action_func(enhanced_ctx, agent, **action_params)
456
+ else:
457
+ # Get model from settings, then language_model_kwargs, then default
458
+ model = self.settings.model or language_model_kwargs.get(
459
+ "model", "openai/gpt-4o-mini"
460
+ )
461
+
462
+ # Remove parameters that will be passed explicitly to avoid duplicates
463
+ filtered_kwargs = {
464
+ k: v
465
+ for k, v in language_model_kwargs.items()
466
+ if k not in ["model", "verbose", "debug"]
467
+ }
468
+
469
+ language_model = LanguageModel(
470
+ model=model,
471
+ verbose=verbose,
472
+ debug=debug,
473
+ **filtered_kwargs,
474
+ )
475
+ # Pass history to context if available
476
+ history = getattr(self, "_history", None)
477
+ if history:
478
+ enhanced_ctx.metadata["history"] = history
479
+
480
+ if asyncio.iscoroutinefunction(self.action_func):
481
+ result = await self.action_func(
482
+ enhanced_ctx, language_model, **action_params
483
+ )
484
+ else:
485
+ result = self.action_func(
486
+ enhanced_ctx, language_model, **action_params
487
+ )
488
+ else:
489
+ # New mode: framework handles language model internally
490
+ # Build the user message from the action parameters
491
+ user_message = ""
492
+ if action_params:
493
+ if len(action_params) == 1:
494
+ # Single parameter - use its value directly
495
+ param_value = list(action_params.values())[0]
496
+ user_message = str(param_value)
497
+ else:
498
+ # Multiple parameters - format them clearly
499
+ param_list = "\n".join(
500
+ f"{k}: {v}" for k, v in action_params.items()
501
+ )
502
+ user_message = param_list
503
+ else:
504
+ # No parameters - check if we have previous conversation history
505
+ # If we do, don't add an empty user message
506
+ user_message = ""
507
+
508
+ # Combine global system prompt with field-level instructions and state
509
+ combined_instructions = global_system_prompt
510
+ if field_instructions and field_instructions not in combined_instructions:
511
+ if combined_instructions:
512
+ combined_instructions += f"\n\n{field_instructions}"
513
+ else:
514
+ combined_instructions = field_instructions
515
+
516
+ # Add state to instructions if available
517
+ if current_state is not None:
518
+ state_str = convert_to_text(current_state, show_defaults=False)
519
+ if state_str:
520
+ combined_instructions += f"\n\nContext: {state_str}"
521
+
522
+ # Get verbose/debug flags and language model kwargs from the node
523
+ verbose = getattr(self, "_verbose", self.settings.verbose)
524
+ debug = getattr(self, "_debug", self.settings.debug)
525
+ language_model_kwargs = getattr(self, "_language_model_kwargs", {})
526
+
527
+ # Get end strategy parameters from node or settings
528
+ max_steps = getattr(self, "_max_steps", self.settings.max_steps)
529
+ end_strategy = getattr(self, "_end_strategy", self.settings.end_strategy)
530
+ end_tool = getattr(self, "_end_tool", self.settings.end_tool)
531
+
532
+ # Determine if we need to use Agent or LanguageModel
533
+ if self.settings.tools or self.settings.instructions:
534
+ # Use Agent for complex operations with tools/instructions
535
+ # Get model from settings, then language_model_kwargs, then default
536
+ model = self.settings.model or language_model_kwargs.get(
537
+ "model", "openai/gpt-4o-mini"
538
+ )
539
+
540
+ # Remove parameters that will be passed explicitly to avoid duplicates
541
+ filtered_kwargs = {
542
+ k: v
543
+ for k, v in language_model_kwargs.items()
544
+ if k
545
+ not in [
546
+ "model",
547
+ "name",
548
+ "instructions",
549
+ "tools",
550
+ "max_steps",
551
+ "end_strategy",
552
+ "end_tool",
553
+ "verbose",
554
+ "debug",
555
+ ]
556
+ }
557
+
558
+ agent = Agent(
559
+ name=self.settings.name or self.action_name,
560
+ instructions=self.settings.instructions or combined_instructions,
561
+ model=model,
562
+ tools=self.settings.tools,
563
+ max_steps=max_steps,
564
+ end_strategy=end_strategy,
565
+ end_tool=end_tool,
566
+ verbose=verbose,
567
+ debug=debug,
568
+ **filtered_kwargs,
569
+ )
570
+
571
+ # Get history if available
572
+ history = getattr(self, "_history", None)
573
+
574
+ # Check if we have previous conversation history from the graph execution
575
+ previous_messages = getattr(self, "_graph_messages", [])
576
+
577
+ # Store the current user message for history building
578
+ if user_message:
579
+ self._current_user_message = user_message
580
+
581
+ # Run the agent with the user message and history
582
+ if history:
583
+ # If history is provided, we need to combine it with the user message
584
+ # The history should be the conversation context, and user_message is the new input
585
+ combined_messages = parse_messages_input(history)
586
+ combined_messages.extend(previous_messages)
587
+ if user_message: # Only add non-empty user messages
588
+ combined_messages.append(
589
+ {"role": "user", "content": user_message}
590
+ )
591
+ agent_result = await agent.async_run(combined_messages)
592
+ elif previous_messages:
593
+ # If we have previous messages from the graph, use them
594
+ combined_messages = previous_messages.copy()
595
+ if user_message: # Only add non-empty user messages
596
+ combined_messages.append(
597
+ {"role": "user", "content": user_message}
598
+ )
599
+ agent_result = await agent.async_run(combined_messages)
600
+ else:
601
+ # Only run with user message if it's not empty
602
+ if user_message:
603
+ agent_result = await agent.async_run(user_message)
604
+ else:
605
+ # If no user message and no history, we can't run the agent
606
+ raise ValueError(
607
+ "No user message or history provided for agent execution"
608
+ )
609
+ result = agent_result.output
610
+ else:
611
+ # Use LanguageModel for simple operations
612
+ # Get model from settings, then language_model_kwargs, then default
613
+ model = self.settings.model or language_model_kwargs.get(
614
+ "model", "openai/gpt-4o-mini"
615
+ )
616
+
617
+ # Remove parameters that will be passed explicitly to avoid duplicates
618
+ filtered_kwargs = {
619
+ k: v
620
+ for k, v in language_model_kwargs.items()
621
+ if k not in ["model", "verbose", "debug"]
622
+ }
623
+
624
+ language_model = LanguageModel(
625
+ model=model,
626
+ verbose=verbose,
627
+ debug=debug,
628
+ **filtered_kwargs,
629
+ )
630
+
631
+ # Get history if available
632
+ history = getattr(self, "_history", None)
633
+
634
+ # Check if we have previous conversation history from the graph execution
635
+ previous_messages = getattr(self, "_graph_messages", [])
636
+
637
+ # Create messages using the language model utils
638
+ if history:
639
+ # If history is provided, use it as the base messages
640
+ messages = parse_messages_input(
641
+ history, instructions=combined_instructions
642
+ )
643
+ # Add any previous graph messages
644
+ messages.extend(previous_messages)
645
+ # Then add the user message from action parameters
646
+ if user_message: # Only add non-empty user messages
647
+ messages.append({"role": "user", "content": user_message})
648
+ elif previous_messages:
649
+ # If we have previous messages from the graph, use them
650
+ messages = parse_messages_input(
651
+ "", instructions=combined_instructions
652
+ )
653
+ messages.extend(previous_messages)
654
+ if user_message: # Only add non-empty user messages
655
+ messages.append({"role": "user", "content": user_message})
656
+ else:
657
+ # Otherwise, use the user message (if not empty)
658
+ if user_message:
659
+ messages = parse_messages_input(
660
+ user_message, instructions=combined_instructions
661
+ )
662
+ else:
663
+ # If no user message and no history, just use instructions
664
+ messages = parse_messages_input(
665
+ "", instructions=combined_instructions
666
+ )
667
+ messages = consolidate_system_messages(messages)
668
+
669
+ # Store the current user message for history building
670
+ if user_message:
671
+ self._current_user_message = user_message
672
+
673
+ # Run the language model with the consolidated messages
674
+ lm_result = await language_model.async_run(messages)
675
+ result = lm_result.output
676
+
677
+ # Get the return type annotation to determine expected output type
678
+ return_type = sig.return_annotation
679
+ if return_type != inspect.Parameter.empty and return_type != str:
680
+ # If the action expects a specific return type, try to parse it
681
+ # For now, we'll just return the string result
682
+ # In a full implementation, we'd use structured output parsing
683
+ pass
684
+
685
+ # Handle the result based on settings
686
+ if isinstance(result, (BaseNode, End)):
687
+ return result
688
+ elif self.settings.terminates:
689
+ return End(result)
690
+ else:
691
+ # Check if there's a next action defined
692
+ if self.settings.next:
693
+ # Handle different types of next specifications
694
+ next_action_name = None
695
+
696
+ if isinstance(self.settings.next, str):
697
+ # Simple string case
698
+ next_action_name = self.settings.next
699
+ elif isinstance(self.settings.next, list):
700
+ # List case - for now, just pick the first one
701
+ # In the future, this could execute all in parallel
702
+ if self.settings.next:
703
+ next_action_name = self.settings.next[0]
704
+ elif isinstance(self.settings.next, SelectionStrategy):
705
+ # Selection strategy case - use the strategy to pick an action
706
+ context = {
707
+ "result": result,
708
+ "state": getattr(self, "_state", None),
709
+ "messages": getattr(self, "_graph_messages", []),
710
+ }
711
+ # If using all actions, pass them in the context
712
+ if self.settings.next._use_all_actions and hasattr(
713
+ self, "_graph_action_nodes"
714
+ ):
715
+ context["all_actions"] = list(self._graph_action_nodes.keys())
716
+ next_action_name = self.settings.next.select(context)
717
+ else:
718
+ # Invalid type for next
719
+ return End(result)
720
+
721
+ # Find the next node class from the graph's action nodes
722
+ if hasattr(self, "_graph_action_nodes") and next_action_name:
723
+ next_node_class = self._graph_action_nodes.get(next_action_name)
724
+ if next_node_class:
725
+ # Create the next node instance
726
+ # For graph flow, we don't pass the result as a parameter
727
+ # The conversation history will contain the context
728
+ next_node = next_node_class()
729
+
730
+ # Copy over any graph-specific attributes
731
+ for attr in [
732
+ "_graph_docstring",
733
+ "_verbose",
734
+ "_debug",
735
+ "_language_model_kwargs",
736
+ "_history",
737
+ "_state",
738
+ "_graph_action_nodes",
739
+ "_execution_tracker",
740
+ ]:
741
+ if hasattr(self, attr):
742
+ setattr(next_node, attr, getattr(self, attr))
743
+
744
+ # Build up the conversation history for the next node
745
+ current_messages = getattr(self, "_graph_messages", [])
746
+ # Add the current interaction to the conversation history
747
+ # Only add the user message if it was actually provided (not empty)
748
+ if (
749
+ hasattr(self, "_current_user_message")
750
+ and self._current_user_message
751
+ ):
752
+ current_messages.append(
753
+ {"role": "user", "content": self._current_user_message}
754
+ )
755
+ # Add the assistant response from this node
756
+ current_messages.append(
757
+ {"role": "assistant", "content": str(result)}
758
+ )
759
+ next_node._graph_messages = current_messages
760
+
761
+ return next_node
762
+
763
+ # If we can't find any valid next node, terminate
764
+ return End(result)
765
+ else:
766
+ # No next action defined, terminate
767
+ return End(result)
768
+
769
+
770
+ class ActionDecorator:
771
+ """Decorator for creating actions that become nodes in the graph."""
772
+
773
+ def __init__(self):
774
+ self._actions: Dict[str, Type[ActionNode]] = {}
775
+ self._start_action: Optional[str] = None
776
+
777
+ def __call__(
778
+ self,
779
+ func: Optional[Callable] = None,
780
+ *,
781
+ model: Optional[LanguageModelName | str] = None,
782
+ temperature: Optional[float] = None,
783
+ max_tokens: Optional[int] = None,
784
+ tools: Optional[List[Callable]] = None,
785
+ start: bool = False,
786
+ terminates: bool = False,
787
+ xml: Optional[str] = None,
788
+ next: Optional[Union[str, List[str], SelectionStrategy]] = None,
789
+ read_history: bool = False,
790
+ persist_history: bool = False,
791
+ condition: Optional[str] = None,
792
+ name: Optional[str] = None,
793
+ instructions: Optional[str] = None,
794
+ verbose: bool = False,
795
+ debug: bool = False,
796
+ # Agent end strategy parameters
797
+ max_steps: Optional[int] = None,
798
+ end_strategy: Optional[Literal["tool"]] = None,
799
+ end_tool: Optional[Callable] = None,
800
+ **kwargs: Any,
801
+ ) -> Union[Callable, Type[ActionNode]]:
802
+ """Main action decorator."""
803
+
804
+ settings = ActionSettings(
805
+ model=model,
806
+ temperature=temperature,
807
+ max_tokens=max_tokens,
808
+ tools=tools or [],
809
+ start=start,
810
+ terminates=terminates,
811
+ xml=xml,
812
+ next=next,
813
+ read_history=read_history,
814
+ persist_history=persist_history,
815
+ condition=condition,
816
+ name=name,
817
+ instructions=instructions,
818
+ verbose=verbose,
819
+ debug=debug,
820
+ max_steps=max_steps,
821
+ end_strategy=end_strategy,
822
+ end_tool=end_tool,
823
+ kwargs=kwargs,
824
+ )
825
+
826
+ def decorator(f: Callable) -> Callable:
827
+ action_name = name or f.__name__
828
+
829
+ # Create a dynamic ActionNode class for this specific action with unique name
830
+ class DynamicActionNode(ActionNode[StateT]):
831
+ def __init__(self, **action_params):
832
+ super().__init__(
833
+ action_name=action_name,
834
+ action_func=f,
835
+ settings=settings,
836
+ **action_params,
837
+ )
838
+
839
+ @classmethod
840
+ def get_node_id(cls):
841
+ """Override to provide unique node ID based on action name."""
842
+ return f"DynamicActionNode_{action_name}"
843
+
844
+ # Store the action
845
+ self._actions[action_name] = DynamicActionNode
846
+ if start:
847
+ if self._start_action is not None:
848
+ raise ValueError(
849
+ f"Multiple start actions: {self._start_action} and {action_name}"
850
+ )
851
+ self._start_action = action_name
852
+
853
+ # Return the original function with metadata attached
854
+ f._action_name = action_name
855
+ f._action_settings = settings
856
+ f._action_node_class = DynamicActionNode
857
+ f._is_start = start
858
+
859
+ return f
860
+
861
+ if func is None:
862
+ return decorator
863
+ else:
864
+ return decorator(func)
865
+
866
+ def start(
867
+ self, func: Optional[Callable] = None, **kwargs
868
+ ) -> Union[Callable, Type[ActionNode]]:
869
+ """Decorator for start actions."""
870
+ return self.__call__(func, start=True, **kwargs)
871
+
872
+ def end(
873
+ self, func: Optional[Callable] = None, **kwargs
874
+ ) -> Union[Callable, Type[ActionNode]]:
875
+ """Decorator for end actions."""
876
+ return self.__call__(func, terminates=True, **kwargs)
877
+
878
+
879
+ # Global action decorator
880
+ action = ActionDecorator()
881
+
882
+
883
+ class GraphBuilder(Generic[StateT, T]):
884
+ """Builder for creating graphs with plugins and configuration."""
885
+
886
+ def __init__(self, graph_class: Type["BaseGraph[StateT, T]"]):
887
+ self.graph_class = graph_class
888
+ self.plugins: List[BasePlugin] = []
889
+ self.global_model: Optional[LanguageModelName] = None
890
+ self.global_settings: Dict[str, Any] = {}
891
+
892
+ def with_plugin(self, plugin: BasePlugin) -> "GraphBuilder[StateT, T]":
893
+ """Add a plugin to the graph."""
894
+ self.plugins.append(plugin)
895
+ return self
896
+
897
+ def with_model(self, model: LanguageModelName) -> "GraphBuilder[StateT, T]":
898
+ """Set the global model for the graph."""
899
+ self.global_model = model
900
+ return self
901
+
902
+ def with_settings(self, **settings: Any) -> "GraphBuilder[StateT, T]":
903
+ """Set global settings for the graph."""
904
+ self.global_settings.update(settings)
905
+ return self
906
+
907
+ def build(self) -> "BaseGraph[StateT, T]":
908
+ """Build the graph instance."""
909
+ instance = self.graph_class()
910
+ instance._plugins = self.plugins
911
+ instance._global_model = self.global_model
912
+ instance._global_settings = self.global_settings
913
+ instance._initialize()
914
+ return instance
915
+
916
+
917
+ class BaseGraph(Generic[StateT, T]):
918
+ """Base class for graphs that provides action decorator support on top of pydantic-graph."""
919
+
920
+ def __init__(
921
+ self,
922
+ state: Optional[StateT] = None,
923
+ *,
924
+ model: Optional[LanguageModelName | str] = None,
925
+ temperature: Optional[float] = None,
926
+ max_tokens: Optional[int] = None,
927
+ tools: Optional[List[Callable]] = None,
928
+ verbose: bool = False,
929
+ debug: bool = False,
930
+ max_steps: Optional[int] = None,
931
+ end_strategy: Optional[Literal["tool"]] = None,
932
+ end_tool: Optional[Callable] = None,
933
+ summarize_tools: bool = True,
934
+ summarize_tools_with_model: bool = False,
935
+ plugins: Optional[List[BasePlugin]] = None,
936
+ **kwargs: Any,
937
+ ):
938
+ self._plugins: List[BasePlugin] = plugins or []
939
+ self._global_model: Optional[LanguageModelName] = model
940
+ self._global_settings: Dict[str, Any] = {
941
+ "temperature": temperature,
942
+ "max_tokens": max_tokens,
943
+ "tools": tools,
944
+ "verbose": verbose,
945
+ "debug": debug,
946
+ "max_steps": max_steps,
947
+ "end_strategy": end_strategy,
948
+ "end_tool": end_tool,
949
+ "summarize_tools": summarize_tools,
950
+ "summarize_tools_with_model": summarize_tools_with_model,
951
+ **kwargs,
952
+ }
953
+ # Remove None values from settings
954
+ self._global_settings = {
955
+ k: v for k, v in self._global_settings.items() if v is not None
956
+ }
957
+
958
+ self._pydantic_graph: Optional[PydanticGraph] = None
959
+ self._action_nodes: Dict[str, Type[ActionNode]] = {}
960
+ self._start_action_name: Optional[str] = None
961
+ self._start_action_func: Optional[Callable] = None
962
+ self._state: Optional[StateT] = state
963
+ self._state_class: Optional[Type[StateT]] = None
964
+ # Initialize the graph automatically
965
+ self._initialize()
966
+
967
+ def _initialize(self) -> None:
968
+ """Initialize the graph by collecting actions and creating the pydantic graph."""
969
+ self._collect_state_class()
970
+ self._collect_actions()
971
+ self._create_pydantic_graph()
972
+
973
+ def _collect_state_class(self) -> None:
974
+ """Collect the State class if defined in the graph."""
975
+ # Look for a State class defined in the graph
976
+ for attr_name in dir(self.__class__):
977
+ attr = getattr(self.__class__, attr_name)
978
+ if (
979
+ isinstance(attr, type)
980
+ and attr_name == "State"
981
+ and attr != self.__class__
982
+ ):
983
+ self._state_class = attr
984
+ # If no state was provided in constructor, try to create default instance
985
+ if self._state is None:
986
+ try:
987
+ if hasattr(attr, "__call__"):
988
+ self._state = attr()
989
+ except Exception:
990
+ # If we can't create a default instance, leave it as None
991
+ pass
992
+ break
993
+
994
+ def _collect_actions(self) -> None:
995
+ """Collect all actions defined in the graph class."""
996
+ actions_found = []
997
+ start_action = None
998
+ end_action = None
999
+
1000
+ # Get the graph class docstring for global system prompt
1001
+ graph_docstring = self.__class__.__doc__ or ""
1002
+
1003
+ for attr_name in dir(self):
1004
+ attr = getattr(self, attr_name)
1005
+ if hasattr(attr, "_action_name"):
1006
+ action_name = attr._action_name
1007
+ action_node_class = attr._action_node_class
1008
+
1009
+ self._action_nodes[action_name] = action_node_class
1010
+ actions_found.append((action_name, attr))
1011
+
1012
+ if hasattr(attr, "_is_start") and attr._is_start:
1013
+ if self._start_action_name is not None:
1014
+ raise ValueError(
1015
+ f"Multiple start actions: {self._start_action_name} and {action_name}"
1016
+ )
1017
+ self._start_action_name = action_name
1018
+ self._start_action_func = attr
1019
+ start_action = attr
1020
+
1021
+ # Check if this is an end action (terminates=True)
1022
+ if (
1023
+ hasattr(attr, "_action_settings")
1024
+ and attr._action_settings.terminates
1025
+ ):
1026
+ end_action = attr
1027
+
1028
+ # If no explicit start action was defined and we have exactly one action,
1029
+ # automatically make it the start action
1030
+ if self._start_action_name is None and len(actions_found) == 1:
1031
+ action_name, action_func = actions_found[0]
1032
+ self._start_action_name = action_name
1033
+ self._start_action_func = action_func
1034
+
1035
+ # Special case: If we have exactly 2 actions (start -> end), automatically set up routing
1036
+ if len(actions_found) == 2 and start_action and end_action:
1037
+ # Check if the start action doesn't already have a 'next' defined
1038
+ if start_action._action_settings.next is None:
1039
+ # Automatically set the start action to route to the end action
1040
+ start_action._action_settings.next = end_action._action_name
1041
+
1042
+ # Store the graph docstring in all action nodes for access during execution
1043
+ for action_node_class in self._action_nodes.values():
1044
+ # We'll add this to the action node instances when they're created
1045
+ action_node_class._graph_docstring = graph_docstring
1046
+
1047
+ def _create_pydantic_graph(self) -> None:
1048
+ """Create the underlying pydantic graph from collected actions."""
1049
+ if not self._action_nodes:
1050
+ raise ValueError("No actions defined in graph")
1051
+
1052
+ # Create the pydantic graph with the node classes
1053
+ node_classes = list(self._action_nodes.values())
1054
+ self._pydantic_graph = PydanticGraph(nodes=node_classes)
1055
+
1056
+ def _get_start_action_signature(self) -> inspect.Signature:
1057
+ """Get the signature of the start action for type-safe run methods."""
1058
+ if self._start_action_func is None:
1059
+ return inspect.Signature([])
1060
+
1061
+ sig = inspect.signature(self._start_action_func)
1062
+ # Filter out 'self', 'ctx'/'context', 'agent', 'language_model' parameters
1063
+ params = []
1064
+ for param_name, param in sig.parameters.items():
1065
+ if param_name not in ("self", "ctx", "context", "agent", "language_model"):
1066
+ params.append(param)
1067
+
1068
+ return inspect.Signature(params)
1069
+
1070
+ def run(
1071
+ self,
1072
+ *args,
1073
+ state: Optional[StateT] = None,
1074
+ history: Optional[AgentMessages] = None,
1075
+ verbose: bool = False,
1076
+ debug: bool = False,
1077
+ **kwargs,
1078
+ ) -> GraphResponse[T, StateT]:
1079
+ """
1080
+ Run the graph with the given parameters.
1081
+ The signature is dynamically determined by the start action.
1082
+
1083
+ Args:
1084
+ *args: Arguments for the start action
1085
+ state: Optional state object to use for the execution
1086
+ history: Optional chat history in various formats (str, messages list, History object)
1087
+ verbose: Enable verbose logging
1088
+ debug: Enable debug logging
1089
+ **kwargs: Additional keyword arguments for the start action and language model
1090
+
1091
+ Returns:
1092
+ GraphResponse containing the execution result and metadata
1093
+ """
1094
+
1095
+ if self._start_action_name is None:
1096
+ raise ValueError("No start action defined")
1097
+
1098
+ # Get the start action node class
1099
+ start_node_class = self._action_nodes[self._start_action_name]
1100
+
1101
+ # Create the start node instance with the provided arguments
1102
+ start_sig = self._get_start_action_signature()
1103
+
1104
+ # Separate language model kwargs from start action kwargs
1105
+ language_model_kwargs = {}
1106
+ start_action_kwargs = {}
1107
+
1108
+ # Language model specific parameters
1109
+ lm_params = {
1110
+ "temperature",
1111
+ "max_tokens",
1112
+ "top_p",
1113
+ "frequency_penalty",
1114
+ "presence_penalty",
1115
+ "stop",
1116
+ "stream",
1117
+ "response_format",
1118
+ "seed",
1119
+ "tools",
1120
+ "tool_choice",
1121
+ "parallel_tool_calls",
1122
+ "functions",
1123
+ "function_call",
1124
+ "user",
1125
+ "system",
1126
+ "n",
1127
+ "echo",
1128
+ "logprobs",
1129
+ "top_logprobs",
1130
+ "suffix",
1131
+ "max_retries",
1132
+ "timeout",
1133
+ "model",
1134
+ "type",
1135
+ "instructor_mode",
1136
+ "max_steps",
1137
+ "end_strategy",
1138
+ "end_tool",
1139
+ }
1140
+
1141
+ for key, value in kwargs.items():
1142
+ if key in lm_params:
1143
+ language_model_kwargs[key] = value
1144
+ else:
1145
+ start_action_kwargs[key] = value
1146
+
1147
+ # Bind arguments to start action parameters
1148
+ try:
1149
+ bound_args = start_sig.bind(*args, **start_action_kwargs)
1150
+ bound_args.apply_defaults()
1151
+ except TypeError as e:
1152
+ raise ValueError(
1153
+ f"Invalid arguments for start action '{self._start_action_name}': {e}"
1154
+ )
1155
+
1156
+ start_node = start_node_class(**bound_args.arguments)
1157
+ # Pass the graph docstring to the node for global system prompt
1158
+ start_node._graph_docstring = self.__class__.__doc__ or ""
1159
+
1160
+ # Merge global settings with provided kwargs
1161
+ merged_settings = self._global_settings.copy()
1162
+ merged_settings.update(language_model_kwargs)
1163
+
1164
+ # Pass verbose/debug flags (prefer explicit params over global settings)
1165
+ start_node._verbose = (
1166
+ verbose if verbose else merged_settings.get("verbose", False)
1167
+ )
1168
+ start_node._debug = debug if debug else merged_settings.get("debug", False)
1169
+ start_node._language_model_kwargs = merged_settings
1170
+
1171
+ # Pass history if provided
1172
+ start_node._history = history
1173
+ # Pass the graph's action nodes for routing
1174
+ start_node._graph_action_nodes = self._action_nodes
1175
+
1176
+ # Initialize execution tracking
1177
+ self._execution_tracker = []
1178
+ start_node._execution_tracker = self._execution_tracker
1179
+
1180
+ # Pass end strategy parameters (from merged settings)
1181
+ if "max_steps" in merged_settings:
1182
+ start_node._max_steps = merged_settings["max_steps"]
1183
+ if "end_strategy" in merged_settings:
1184
+ start_node._end_strategy = merged_settings["end_strategy"]
1185
+ if "end_tool" in merged_settings:
1186
+ start_node._end_tool = merged_settings["end_tool"]
1187
+
1188
+ # Run the pydantic graph
1189
+ if not self._pydantic_graph:
1190
+ raise ValueError("Graph not initialized")
1191
+
1192
+ # Use the provided state or the graph's state
1193
+ execution_state = state if state is not None else self._state
1194
+ # Pass state to the node
1195
+ start_node._state = execution_state
1196
+
1197
+ # Execute the graph using pydantic-graph
1198
+ try:
1199
+ # For now, use sync execution - would implement proper async support
1200
+ result = self._pydantic_graph.run_sync(start_node, state=execution_state)
1201
+
1202
+ # Extract the actual output from pydantic-graph result
1203
+ if hasattr(result, "data"):
1204
+ output = result.data
1205
+ elif hasattr(result, "output"):
1206
+ output = result.output
1207
+ else:
1208
+ output = str(result)
1209
+
1210
+ # Get nodes executed from the execution tracker
1211
+ nodes_executed = getattr(self, "_execution_tracker", [])
1212
+
1213
+ # If no nodes tracked, at least include the start node
1214
+ if not nodes_executed:
1215
+ nodes_executed = [self._start_action_name]
1216
+
1217
+ # Create our response object
1218
+ return GraphResponse(
1219
+ type="graph",
1220
+ model=self._global_model or "openai/gpt-4o-mini",
1221
+ output=output,
1222
+ content=str(output),
1223
+ completion=None,
1224
+ state=execution_state,
1225
+ history=[], # Would be populated from pydantic-graph execution
1226
+ start_node=self._start_action_name,
1227
+ nodes_executed=nodes_executed,
1228
+ metadata={},
1229
+ )
1230
+
1231
+ except Exception as e:
1232
+ raise RuntimeError(f"Graph execution failed: {e}") from e
1233
+
1234
+ def iter(
1235
+ self,
1236
+ *args,
1237
+ state: Optional[StateT] = None,
1238
+ history: Optional[AgentMessages] = None,
1239
+ verbose: bool = False,
1240
+ debug: bool = False,
1241
+ max_steps: Optional[int] = None,
1242
+ end_strategy: Optional[Literal["tool"]] = None,
1243
+ end_tool: Optional[Callable] = None,
1244
+ **kwargs,
1245
+ ) -> GraphStream[T, StateT]:
1246
+ """
1247
+ Create an iterator for the graph execution.
1248
+ The signature is dynamically determined by the start action.
1249
+
1250
+ Args:
1251
+ *args: Arguments for the start action
1252
+ state: Optional state object to use for the execution
1253
+ history: Optional chat history in various formats (str, messages list, History object)
1254
+ verbose: Enable verbose logging
1255
+ debug: Enable debug logging
1256
+ max_steps: Maximum number of steps to execute
1257
+ end_strategy: Strategy for ending execution
1258
+ end_tool: Tool to use for ending execution
1259
+ **kwargs: Additional keyword arguments for the start action and language model
1260
+
1261
+ Returns:
1262
+ GraphStream that can be iterated over to get each execution step
1263
+ """
1264
+
1265
+ if self._start_action_name is None:
1266
+ raise ValueError("No start action defined")
1267
+
1268
+ # Get the start action node class
1269
+ start_node_class = self._action_nodes[self._start_action_name]
1270
+
1271
+ # Create the start node instance with the provided arguments
1272
+ start_sig = self._get_start_action_signature()
1273
+
1274
+ # Separate language model kwargs from start action kwargs
1275
+ language_model_kwargs = {}
1276
+ start_action_kwargs = {}
1277
+
1278
+ # Language model specific parameters
1279
+ lm_params = {
1280
+ "temperature",
1281
+ "max_tokens",
1282
+ "top_p",
1283
+ "frequency_penalty",
1284
+ "presence_penalty",
1285
+ "stop",
1286
+ "stream",
1287
+ "response_format",
1288
+ "seed",
1289
+ "tools",
1290
+ "tool_choice",
1291
+ "parallel_tool_calls",
1292
+ "functions",
1293
+ "function_call",
1294
+ "user",
1295
+ "system",
1296
+ "n",
1297
+ "echo",
1298
+ "logprobs",
1299
+ "top_logprobs",
1300
+ "suffix",
1301
+ "max_retries",
1302
+ "timeout",
1303
+ "model",
1304
+ "type",
1305
+ "instructor_mode",
1306
+ "max_steps",
1307
+ "end_strategy",
1308
+ "end_tool",
1309
+ }
1310
+
1311
+ for key, value in kwargs.items():
1312
+ if key in lm_params:
1313
+ language_model_kwargs[key] = value
1314
+ else:
1315
+ start_action_kwargs[key] = value
1316
+
1317
+ try:
1318
+ bound_args = start_sig.bind(*args, **start_action_kwargs)
1319
+ bound_args.apply_defaults()
1320
+ except TypeError as e:
1321
+ raise ValueError(
1322
+ f"Invalid arguments for start action '{self._start_action_name}': {e}"
1323
+ )
1324
+
1325
+ start_node = start_node_class(**bound_args.arguments)
1326
+ # Pass the graph docstring to the node for global system prompt
1327
+ start_node._graph_docstring = self.__class__.__doc__ or ""
1328
+
1329
+ # Merge global settings with provided kwargs
1330
+ merged_settings = self._global_settings.copy()
1331
+ merged_settings.update(language_model_kwargs)
1332
+
1333
+ # Pass verbose/debug flags (prefer explicit params over global settings)
1334
+ start_node._verbose = (
1335
+ verbose if verbose else merged_settings.get("verbose", False)
1336
+ )
1337
+ start_node._debug = debug if debug else merged_settings.get("debug", False)
1338
+ start_node._language_model_kwargs = merged_settings
1339
+
1340
+ # Pass history if provided
1341
+ start_node._history = history
1342
+ # Pass the graph's action nodes for routing
1343
+ start_node._graph_action_nodes = self._action_nodes
1344
+
1345
+ # Pass end strategy parameters (prefer explicit params over merged settings)
1346
+ start_node._max_steps = (
1347
+ max_steps if max_steps is not None else merged_settings.get("max_steps")
1348
+ )
1349
+ start_node._end_strategy = (
1350
+ end_strategy
1351
+ if end_strategy is not None
1352
+ else merged_settings.get("end_strategy")
1353
+ )
1354
+ start_node._end_tool = (
1355
+ end_tool if end_tool is not None else merged_settings.get("end_tool")
1356
+ )
1357
+
1358
+ # Use the provided state or the graph's state
1359
+ execution_state = state if state is not None else self._state
1360
+ # Pass state to the node
1361
+ start_node._state = execution_state
1362
+
1363
+ # Create and return GraphStream
1364
+ return GraphStream(
1365
+ graph=self,
1366
+ start_node=start_node,
1367
+ state=execution_state,
1368
+ verbose=verbose,
1369
+ debug=debug,
1370
+ max_steps=max_steps,
1371
+ end_strategy=end_strategy,
1372
+ end_tool=end_tool,
1373
+ **language_model_kwargs,
1374
+ )
1375
+
1376
+ async def async_run(
1377
+ self,
1378
+ *args,
1379
+ state: Optional[StateT] = None,
1380
+ history: Optional[AgentMessages] = None,
1381
+ verbose: bool = False,
1382
+ debug: bool = False,
1383
+ max_steps: Optional[int] = None,
1384
+ end_strategy: Optional[Literal["tool"]] = None,
1385
+ end_tool: Optional[Callable] = None,
1386
+ **kwargs,
1387
+ ) -> GraphResponse[T, StateT]:
1388
+ """Async version of run.
1389
+
1390
+ Args:
1391
+ *args: Arguments for the start action
1392
+ state: Optional state object to use for the execution
1393
+ history: Optional chat history in various formats (str, messages list, History object)
1394
+ verbose: Enable verbose logging
1395
+ debug: Enable debug logging
1396
+ **kwargs: Additional keyword arguments for the start action and language model
1397
+
1398
+ Returns:
1399
+ GraphResponse containing the execution result and metadata
1400
+ """
1401
+
1402
+ if self._start_action_name is None:
1403
+ raise ValueError("No start action defined")
1404
+
1405
+ # Get the start action node class
1406
+ start_node_class = self._action_nodes[self._start_action_name]
1407
+
1408
+ # Create the start node instance with the provided arguments
1409
+ start_sig = self._get_start_action_signature()
1410
+
1411
+ # Separate language model kwargs from start action kwargs
1412
+ language_model_kwargs = {}
1413
+ start_action_kwargs = {}
1414
+
1415
+ # Language model specific parameters
1416
+ lm_params = {
1417
+ "temperature",
1418
+ "max_tokens",
1419
+ "top_p",
1420
+ "frequency_penalty",
1421
+ "presence_penalty",
1422
+ "stop",
1423
+ "stream",
1424
+ "response_format",
1425
+ "seed",
1426
+ "tools",
1427
+ "tool_choice",
1428
+ "parallel_tool_calls",
1429
+ "functions",
1430
+ "function_call",
1431
+ "user",
1432
+ "system",
1433
+ "n",
1434
+ "echo",
1435
+ "logprobs",
1436
+ "top_logprobs",
1437
+ "suffix",
1438
+ "max_retries",
1439
+ "timeout",
1440
+ "model",
1441
+ "type",
1442
+ "instructor_mode",
1443
+ "max_steps",
1444
+ "end_strategy",
1445
+ "end_tool",
1446
+ }
1447
+
1448
+ for key, value in kwargs.items():
1449
+ if key in lm_params:
1450
+ language_model_kwargs[key] = value
1451
+ else:
1452
+ start_action_kwargs[key] = value
1453
+
1454
+ try:
1455
+ bound_args = start_sig.bind(*args, **start_action_kwargs)
1456
+ bound_args.apply_defaults()
1457
+ except TypeError as e:
1458
+ raise ValueError(
1459
+ f"Invalid arguments for start action '{self._start_action_name}': {e}"
1460
+ )
1461
+
1462
+ start_node = start_node_class(**bound_args.arguments)
1463
+ # Pass the graph docstring to the node for global system prompt
1464
+ start_node._graph_docstring = self.__class__.__doc__ or ""
1465
+
1466
+ # Merge global settings with provided kwargs
1467
+ merged_settings = self._global_settings.copy()
1468
+ merged_settings.update(language_model_kwargs)
1469
+
1470
+ # Pass verbose/debug flags (prefer explicit params over global settings)
1471
+ start_node._verbose = (
1472
+ verbose if verbose else merged_settings.get("verbose", False)
1473
+ )
1474
+ start_node._debug = debug if debug else merged_settings.get("debug", False)
1475
+ start_node._language_model_kwargs = merged_settings
1476
+
1477
+ # Pass history if provided
1478
+ start_node._history = history
1479
+ # Pass the graph's action nodes for routing
1480
+ start_node._graph_action_nodes = self._action_nodes
1481
+
1482
+ # Initialize execution tracking
1483
+ self._execution_tracker = []
1484
+ start_node._execution_tracker = self._execution_tracker
1485
+
1486
+ # Pass end strategy parameters (prefer explicit params over merged settings)
1487
+ start_node._max_steps = (
1488
+ max_steps if max_steps is not None else merged_settings.get("max_steps")
1489
+ )
1490
+ start_node._end_strategy = (
1491
+ end_strategy
1492
+ if end_strategy is not None
1493
+ else merged_settings.get("end_strategy")
1494
+ )
1495
+ start_node._end_tool = (
1496
+ end_tool if end_tool is not None else merged_settings.get("end_tool")
1497
+ )
1498
+
1499
+ # Run the pydantic graph asynchronously
1500
+ if not self._pydantic_graph:
1501
+ raise ValueError("Graph not initialized")
1502
+
1503
+ # Use the provided state or the graph's state
1504
+ execution_state = state if state is not None else self._state
1505
+ # Pass state to the node
1506
+ start_node._state = execution_state
1507
+
1508
+ try:
1509
+ # Execute the graph using pydantic-graph async
1510
+ result = await self._pydantic_graph.run(start_node, state=execution_state)
1511
+
1512
+ # Extract the actual output from pydantic-graph result
1513
+ if hasattr(result, "data"):
1514
+ output = result.data
1515
+ elif hasattr(result, "output"):
1516
+ output = result.output
1517
+ else:
1518
+ output = str(result)
1519
+
1520
+ # Get nodes executed from the execution tracker
1521
+ nodes_executed = getattr(self, "_execution_tracker", [])
1522
+
1523
+ # If no nodes tracked, at least include the start node
1524
+ if not nodes_executed:
1525
+ nodes_executed = [self._start_action_name]
1526
+
1527
+ # Create our response object
1528
+ return GraphResponse(
1529
+ type="graph",
1530
+ model=self._global_model or "openai/gpt-4o-mini",
1531
+ output=output,
1532
+ content=str(output),
1533
+ completion=None,
1534
+ state=execution_state,
1535
+ history=[], # Would be populated from pydantic-graph execution
1536
+ start_node=self._start_action_name,
1537
+ nodes_executed=nodes_executed,
1538
+ metadata={},
1539
+ )
1540
+
1541
+ except Exception as e:
1542
+ raise RuntimeError(f"Async graph execution failed: {e}") from e
1543
+
1544
+ async def async_iter(
1545
+ self,
1546
+ *args,
1547
+ state: Optional[StateT] = None,
1548
+ history: Optional[AgentMessages] = None,
1549
+ verbose: bool = False,
1550
+ debug: bool = False,
1551
+ max_steps: Optional[int] = None,
1552
+ end_strategy: Optional[Literal["tool"]] = None,
1553
+ end_tool: Optional[Callable] = None,
1554
+ **kwargs,
1555
+ ) -> GraphStream[T, StateT]:
1556
+ """Async version of iter.
1557
+
1558
+ Args:
1559
+ *args: Arguments for the start action
1560
+ state: Optional state object to use for the execution
1561
+ history: Optional chat history in various formats (str, messages list, History object)
1562
+ verbose: Enable verbose logging
1563
+ debug: Enable debug logging
1564
+ max_steps: Maximum number of steps to execute
1565
+ end_strategy: Strategy for ending execution
1566
+ end_tool: Tool to use for ending execution
1567
+ **kwargs: Additional keyword arguments for the start action and language model
1568
+
1569
+ Returns:
1570
+ GraphStream that can be iterated over asynchronously
1571
+ """
1572
+
1573
+ if self._start_action_name is None:
1574
+ raise ValueError("No start action defined")
1575
+
1576
+ start_node_class = self._action_nodes[self._start_action_name]
1577
+ start_sig = self._get_start_action_signature()
1578
+
1579
+ # Separate language model kwargs from start action kwargs
1580
+ language_model_kwargs = {}
1581
+ start_action_kwargs = {}
1582
+
1583
+ # Language model specific parameters
1584
+ lm_params = {
1585
+ "temperature",
1586
+ "max_tokens",
1587
+ "top_p",
1588
+ "frequency_penalty",
1589
+ "presence_penalty",
1590
+ "stop",
1591
+ "stream",
1592
+ "response_format",
1593
+ "seed",
1594
+ "tools",
1595
+ "tool_choice",
1596
+ "parallel_tool_calls",
1597
+ "functions",
1598
+ "function_call",
1599
+ "user",
1600
+ "system",
1601
+ "n",
1602
+ "echo",
1603
+ "logprobs",
1604
+ "top_logprobs",
1605
+ "suffix",
1606
+ "max_retries",
1607
+ "timeout",
1608
+ "model",
1609
+ "type",
1610
+ "instructor_mode",
1611
+ "max_steps",
1612
+ "end_strategy",
1613
+ "end_tool",
1614
+ }
1615
+
1616
+ for key, value in kwargs.items():
1617
+ if key in lm_params:
1618
+ language_model_kwargs[key] = value
1619
+ else:
1620
+ start_action_kwargs[key] = value
1621
+
1622
+ try:
1623
+ bound_args = start_sig.bind(*args, **start_action_kwargs)
1624
+ bound_args.apply_defaults()
1625
+ except TypeError as e:
1626
+ raise ValueError(
1627
+ f"Invalid arguments for start action '{self._start_action_name}': {e}"
1628
+ )
1629
+
1630
+ start_node = start_node_class(**bound_args.arguments)
1631
+ # Pass the graph docstring to the node for global system prompt
1632
+ start_node._graph_docstring = self.__class__.__doc__ or ""
1633
+
1634
+ # Merge global settings with provided kwargs
1635
+ merged_settings = self._global_settings.copy()
1636
+ merged_settings.update(language_model_kwargs)
1637
+
1638
+ # Pass verbose/debug flags (prefer explicit params over global settings)
1639
+ start_node._verbose = (
1640
+ verbose if verbose else merged_settings.get("verbose", False)
1641
+ )
1642
+ start_node._debug = debug if debug else merged_settings.get("debug", False)
1643
+ start_node._language_model_kwargs = merged_settings
1644
+
1645
+ # Pass history if provided
1646
+ start_node._history = history
1647
+ # Pass the graph's action nodes for routing
1648
+ start_node._graph_action_nodes = self._action_nodes
1649
+
1650
+ # Pass end strategy parameters (prefer explicit params over merged settings)
1651
+ start_node._max_steps = (
1652
+ max_steps if max_steps is not None else merged_settings.get("max_steps")
1653
+ )
1654
+ start_node._end_strategy = (
1655
+ end_strategy
1656
+ if end_strategy is not None
1657
+ else merged_settings.get("end_strategy")
1658
+ )
1659
+ start_node._end_tool = (
1660
+ end_tool if end_tool is not None else merged_settings.get("end_tool")
1661
+ )
1662
+
1663
+ # Use the provided state or the graph's state
1664
+ execution_state = state if state is not None else self._state
1665
+ # Pass state to the node
1666
+ start_node._state = execution_state
1667
+
1668
+ # Create and return GraphStream
1669
+ return GraphStream(
1670
+ graph=self,
1671
+ start_node=start_node,
1672
+ state=execution_state,
1673
+ verbose=verbose,
1674
+ debug=debug,
1675
+ max_steps=max_steps,
1676
+ end_strategy=end_strategy,
1677
+ end_tool=end_tool,
1678
+ **language_model_kwargs,
1679
+ )
1680
+
1681
+ def visualize(self, filename: str) -> None:
1682
+ """Generate a visualization of the graph using pydantic-graph's mermaid support."""
1683
+ if self._pydantic_graph and self._start_action_name:
1684
+ start_node_class = self._action_nodes.get(self._start_action_name)
1685
+ if start_node_class:
1686
+ # Use pydantic-graph's built-in mermaid generation
1687
+ mermaid_code = self._pydantic_graph.mermaid_code(
1688
+ start_node=start_node_class
1689
+ )
1690
+ with open(filename, "w") as f:
1691
+ f.write(mermaid_code)
1692
+
1693
+ @classmethod
1694
+ def builder(cls) -> GraphBuilder[StateT, T]:
1695
+ """Create a builder for this graph."""
1696
+ return GraphBuilder(cls)
1697
+
1698
+ def as_a2a(
1699
+ self,
1700
+ *,
1701
+ # Worker configuration
1702
+ state: Optional[StateT] = None,
1703
+ # Storage and broker configuration
1704
+ storage: Optional[Any] = None,
1705
+ broker: Optional[Any] = None,
1706
+ # Server configuration
1707
+ host: str = "0.0.0.0",
1708
+ port: int = 8000,
1709
+ reload: bool = False,
1710
+ workers: int = 1,
1711
+ log_level: str = "info",
1712
+ # A2A configuration
1713
+ name: Optional[str] = None,
1714
+ url: Optional[str] = None,
1715
+ version: str = "1.0.0",
1716
+ description: Optional[str] = None,
1717
+ # Advanced configuration
1718
+ lifespan_timeout: int = 30,
1719
+ **uvicorn_kwargs: Any,
1720
+ ) -> "FastA2A": # type: ignore
1721
+ """
1722
+ Convert this graph to an A2A server application.
1723
+
1724
+ This method creates a FastA2A server that can handle A2A requests
1725
+ for this graph instance. It sets up the necessary Worker, Storage,
1726
+ and Broker components automatically.
1727
+
1728
+ Args:
1729
+ state: Initial state for the graph (overrides instance state)
1730
+ storage: Custom storage backend (defaults to InMemoryStorage)
1731
+ broker: Custom broker backend (defaults to InMemoryBroker)
1732
+ host: Host to bind the server to
1733
+ port: Port to bind the server to
1734
+ reload: Enable auto-reload for development
1735
+ workers: Number of worker processes
1736
+ log_level: Logging level
1737
+ name: Graph name for the A2A server
1738
+ url: URL where the graph is hosted
1739
+ version: API version
1740
+ description: API description for the A2A server
1741
+ lifespan_timeout: Timeout for lifespan events
1742
+ **uvicorn_kwargs: Additional arguments passed to uvicorn
1743
+
1744
+ Returns:
1745
+ FastA2A application instance that can be run with uvicorn
1746
+
1747
+ Examples:
1748
+ Convert graph to A2A server:
1749
+ ```python
1750
+ class MyGraph(BaseGraph):
1751
+ @action.start()
1752
+ def process(self, message: str) -> str:
1753
+ return f"Processed: {message}"
1754
+
1755
+ graph = MyGraph()
1756
+ app = graph.as_a2a(port=8080)
1757
+
1758
+ # Run with uvicorn
1759
+ import uvicorn
1760
+ uvicorn.run(app, host="0.0.0.0", port=8080)
1761
+ ```
1762
+
1763
+ Or use the CLI:
1764
+ ```bash
1765
+ uvicorn mymodule:graph.as_a2a() --reload
1766
+ ```
1767
+ """
1768
+ from ..a2a import as_a2a_app
1769
+
1770
+ return as_a2a_app(
1771
+ self,
1772
+ state=state if state is not None else self._state,
1773
+ storage=storage,
1774
+ broker=broker,
1775
+ host=host,
1776
+ port=port,
1777
+ reload=reload,
1778
+ workers=workers,
1779
+ log_level=log_level,
1780
+ name=name or self.__class__.__name__,
1781
+ url=url,
1782
+ version=version,
1783
+ description=description or self.__class__.__doc__,
1784
+ lifespan_timeout=lifespan_timeout,
1785
+ **uvicorn_kwargs,
1786
+ )