agnt5 0.2.8a10__cp310-abi3-manylinux_2_34_x86_64.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.

Potentially problematic release.


This version of agnt5 might be problematic. Click here for more details.

agnt5/workflow.py ADDED
@@ -0,0 +1,1048 @@
1
+ """Workflow component implementation for AGNT5 SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import inspect
8
+ import logging
9
+ import uuid
10
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar, Union, cast
11
+
12
+ from ._schema_utils import extract_function_metadata, extract_function_schemas
13
+ from .context import Context, set_current_context
14
+ from .entity import Entity, EntityState, _get_state_adapter
15
+ from .function import FunctionContext
16
+ from .types import HandlerFunc, WorkflowConfig
17
+ from ._telemetry import setup_module_logger
18
+
19
+ logger = setup_module_logger(__name__)
20
+
21
+ T = TypeVar("T")
22
+
23
+ # Global workflow registry
24
+ _WORKFLOW_REGISTRY: Dict[str, WorkflowConfig] = {}
25
+
26
+
27
+ class WorkflowContext(Context):
28
+ """
29
+ Context for durable workflows.
30
+
31
+ Extends base Context with:
32
+ - State management via WorkflowEntity.state
33
+ - Step tracking and replay
34
+ - Orchestration (task, parallel, gather)
35
+ - Checkpointing (step)
36
+ - Memory scoping (session_id, user_id for multi-level memory)
37
+
38
+ WorkflowContext delegates state to the underlying WorkflowEntity,
39
+ which provides durability and state change tracking for AI workflows.
40
+
41
+ Memory Scoping:
42
+ - run_id: Unique workflow run identifier
43
+ - session_id: For multi-turn conversations (optional)
44
+ - user_id: For user-scoped long-term memory (optional)
45
+ These identifiers enable agents to automatically select the appropriate
46
+ memory scope (run/session/user) via context propagation.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ workflow_entity: "WorkflowEntity", # Forward reference
52
+ run_id: str,
53
+ session_id: Optional[str] = None,
54
+ user_id: Optional[str] = None,
55
+ attempt: int = 0,
56
+ runtime_context: Optional[Any] = None,
57
+ checkpoint_callback: Optional[Callable[[dict], None]] = None,
58
+ ) -> None:
59
+ """
60
+ Initialize workflow context.
61
+
62
+ Args:
63
+ workflow_entity: WorkflowEntity instance managing workflow state
64
+ run_id: Unique workflow run identifier
65
+ session_id: Session identifier for multi-turn conversations (default: run_id)
66
+ user_id: User identifier for user-scoped memory (optional)
67
+ attempt: Retry attempt number (0-indexed)
68
+ runtime_context: RuntimeContext for trace correlation
69
+ checkpoint_callback: Optional callback for sending real-time checkpoints
70
+ """
71
+ super().__init__(run_id, attempt, runtime_context)
72
+ self._workflow_entity = workflow_entity
73
+ self._step_counter: int = 0 # Track step sequence
74
+ self._sequence_number: int = 0 # Global sequence for checkpoints
75
+ self._checkpoint_callback = checkpoint_callback
76
+
77
+ # Memory scoping identifiers
78
+ self.session_id = session_id or run_id # Default: session = run (ephemeral)
79
+ self.user_id = user_id # Optional: user-scoped memory
80
+
81
+ # === State Management ===
82
+
83
+ def _send_checkpoint(self, checkpoint_type: str, checkpoint_data: dict) -> None:
84
+ """
85
+ Send a checkpoint via the checkpoint callback.
86
+
87
+ Args:
88
+ checkpoint_type: Type of checkpoint (e.g., "workflow.state.changed")
89
+ checkpoint_data: Checkpoint payload
90
+ """
91
+ if self._checkpoint_callback:
92
+ self._sequence_number += 1
93
+ checkpoint = {
94
+ "checkpoint_type": checkpoint_type,
95
+ "checkpoint_data": checkpoint_data,
96
+ "sequence_number": self._sequence_number,
97
+ }
98
+ self._checkpoint_callback(checkpoint)
99
+
100
+ @property
101
+ def state(self):
102
+ """
103
+ Delegate to WorkflowEntity.state for durable state management.
104
+
105
+ Returns:
106
+ WorkflowState instance from the workflow entity
107
+
108
+ Example:
109
+ ctx.state.set("status", "processing")
110
+ status = ctx.state.get("status")
111
+ """
112
+ state = self._workflow_entity.state
113
+ # Pass checkpoint callback to state for real-time streaming
114
+ if hasattr(state, "_set_checkpoint_callback"):
115
+ state._set_checkpoint_callback(self._send_checkpoint)
116
+ return state
117
+
118
+ # === Orchestration ===
119
+
120
+ async def task(
121
+ self,
122
+ handler: Union[str, Callable],
123
+ *args: Any,
124
+ **kwargs: Any,
125
+ ) -> Any:
126
+ """
127
+ Execute a function and wait for result.
128
+
129
+ Supports two calling patterns:
130
+
131
+ 1. **Type-safe with function reference (recommended)**:
132
+ ```python
133
+ result = await ctx.task(process_data, arg1, arg2, kwarg=value)
134
+ ```
135
+ Full IDE support, type checking, and refactoring safety.
136
+
137
+ 2. **Legacy string-based (backward compatible)**:
138
+ ```python
139
+ result = await ctx.task("function_name", input=data)
140
+ ```
141
+ String lookup without type safety.
142
+
143
+ Args:
144
+ handler: Either a @function reference (recommended) or string name (legacy)
145
+ *args: Positional arguments to pass to the function
146
+ **kwargs: Keyword arguments to pass to the function
147
+
148
+ Returns:
149
+ Function result
150
+
151
+ Example (type-safe):
152
+ ```python
153
+ @function
154
+ async def process_data(ctx: FunctionContext, data: list, multiplier: int = 2):
155
+ return [x * multiplier for x in data]
156
+
157
+ @workflow
158
+ async def my_workflow(ctx: WorkflowContext):
159
+ # Type-safe call with positional and keyword args
160
+ result = await ctx.task(process_data, [1, 2, 3], multiplier=3)
161
+ return result
162
+ ```
163
+
164
+ Example (legacy):
165
+ ```python
166
+ result = await ctx.task("process_data", input={"data": [1, 2, 3]})
167
+ ```
168
+ """
169
+ from .function import FunctionRegistry
170
+
171
+ # Extract handler name from function reference or use string
172
+ if callable(handler):
173
+ handler_name = handler.__name__
174
+ if not hasattr(handler, "_agnt5_config"):
175
+ raise ValueError(
176
+ f"Function '{handler_name}' is not a registered @function. "
177
+ f"Did you forget to add the @function decorator?"
178
+ )
179
+ else:
180
+ handler_name = handler
181
+
182
+ # Generate unique step name for durability
183
+ step_name = f"{handler_name}_{self._step_counter}"
184
+ self._step_counter += 1
185
+
186
+ # Check if step already completed (for replay)
187
+ if self._workflow_entity.has_completed_step(step_name):
188
+ result = self._workflow_entity.get_completed_step(step_name)
189
+ self._logger.info(f"🔄 Replaying cached step: {step_name}")
190
+ return result
191
+
192
+ # Emit workflow.step.started checkpoint
193
+ self._send_checkpoint(
194
+ "workflow.step.started",
195
+ {
196
+ "step_name": step_name,
197
+ "handler_name": handler_name,
198
+ "input": args or kwargs,
199
+ },
200
+ )
201
+
202
+ # Execute function with OpenTelemetry span
203
+ self._logger.info(f"▶️ Executing new step: {step_name}")
204
+ func_config = FunctionRegistry.get(handler_name)
205
+ if func_config is None:
206
+ raise ValueError(f"Function '{handler_name}' not found in registry")
207
+
208
+ # Import span creation utility and JSON serialization
209
+ from ._core import create_span
210
+ import json
211
+
212
+ # Serialize input data for span attributes
213
+ input_repr = json.dumps({"args": args, "kwargs": kwargs}) if args or kwargs else "{}"
214
+
215
+ # Create span for task execution
216
+ with create_span(
217
+ f"workflow.task.{handler_name}",
218
+ "function",
219
+ self._runtime_context,
220
+ {
221
+ "step_name": step_name,
222
+ "handler_name": handler_name,
223
+ "run_id": self.run_id,
224
+ "input.data": input_repr,
225
+ },
226
+ ) as span:
227
+ # Create FunctionContext for the function execution
228
+ func_ctx = FunctionContext(
229
+ run_id=f"{self.run_id}:task:{handler_name}",
230
+ runtime_context=self._runtime_context,
231
+ )
232
+
233
+ try:
234
+ # Execute function with arguments
235
+ # Support legacy pattern: ctx.task("func_name", input=data) or ctx.task(func_ref, input=data)
236
+ if len(args) == 0 and "input" in kwargs:
237
+ # Legacy pattern - single input parameter
238
+ input_data = kwargs.pop("input") # Remove from kwargs
239
+ result = await func_config.handler(func_ctx, input_data, **kwargs)
240
+ else:
241
+ # Type-safe pattern - pass all args/kwargs
242
+ result = await func_config.handler(func_ctx, *args, **kwargs)
243
+
244
+ # Add output data to span
245
+ try:
246
+ output_repr = json.dumps(result)
247
+ span.set_attribute("output.data", output_repr)
248
+ except (TypeError, ValueError):
249
+ # If result is not JSON serializable, use repr
250
+ span.set_attribute("output.data", repr(result))
251
+
252
+ # Record step completion in WorkflowEntity
253
+ self._workflow_entity.record_step_completion(
254
+ step_name, handler_name, args or kwargs, result
255
+ )
256
+
257
+ # Emit workflow.step.completed checkpoint
258
+ self._send_checkpoint(
259
+ "workflow.step.completed",
260
+ {
261
+ "step_name": step_name,
262
+ "handler_name": handler_name,
263
+ "input": args or kwargs,
264
+ "result": result,
265
+ },
266
+ )
267
+
268
+ return result
269
+
270
+ except Exception as e:
271
+ # Emit workflow.step.error checkpoint
272
+ self._send_checkpoint(
273
+ "workflow.step.error",
274
+ {
275
+ "step_name": step_name,
276
+ "handler_name": handler_name,
277
+ "input": args or kwargs,
278
+ "error": str(e),
279
+ "error_type": type(e).__name__,
280
+ },
281
+ )
282
+
283
+ # Record error in span
284
+ span.set_attribute("error", "true")
285
+ span.set_attribute("error.message", str(e))
286
+ span.set_attribute("error.type", type(e).__name__)
287
+
288
+ # Re-raise to propagate failure
289
+ raise
290
+
291
+ async def parallel(self, *tasks: Awaitable[T]) -> List[T]:
292
+ """
293
+ Run multiple tasks in parallel.
294
+
295
+ Args:
296
+ *tasks: Async tasks to run in parallel
297
+
298
+ Returns:
299
+ List of results in the same order as tasks
300
+
301
+ Example:
302
+ result1, result2 = await ctx.parallel(
303
+ fetch_data(source1),
304
+ fetch_data(source2)
305
+ )
306
+ """
307
+ import asyncio
308
+
309
+ return list(await asyncio.gather(*tasks))
310
+
311
+ async def gather(self, **tasks: Awaitable[T]) -> Dict[str, T]:
312
+ """
313
+ Run tasks in parallel with named results.
314
+
315
+ Args:
316
+ **tasks: Named async tasks to run in parallel
317
+
318
+ Returns:
319
+ Dictionary mapping names to results
320
+
321
+ Example:
322
+ results = await ctx.gather(
323
+ db=query_database(),
324
+ api=fetch_api()
325
+ )
326
+ """
327
+ import asyncio
328
+
329
+ keys = list(tasks.keys())
330
+ values = list(tasks.values())
331
+ results = await asyncio.gather(*values)
332
+ return dict(zip(keys, results))
333
+
334
+ async def step(
335
+ self, name: str, func_or_awaitable: Union[Callable[[], Awaitable[T]], Awaitable[T]]
336
+ ) -> T:
337
+ """
338
+ Checkpoint expensive operations for durability.
339
+
340
+ If workflow crashes, won't re-execute this step on retry.
341
+
342
+ Args:
343
+ name: Unique name for this checkpoint
344
+ func_or_awaitable: Either an async function or awaitable
345
+
346
+ Returns:
347
+ The result of the function/awaitable
348
+
349
+ Example:
350
+ result = await ctx.step("load", load_data())
351
+ """
352
+ import inspect
353
+
354
+ # Check if step already completed (for replay)
355
+ if self._workflow_entity.has_completed_step(name):
356
+ result = self._workflow_entity.get_completed_step(name)
357
+ self._logger.info(f"🔄 Replaying checkpoint: {name}")
358
+ return result
359
+
360
+ # Execute and checkpoint
361
+ if inspect.iscoroutine(func_or_awaitable) or inspect.isawaitable(func_or_awaitable):
362
+ result = await func_or_awaitable
363
+ else:
364
+ result = await func_or_awaitable()
365
+
366
+ # Record step completion
367
+ self._workflow_entity.record_step_completion(name, "checkpoint", None, result)
368
+
369
+ return result
370
+
371
+ async def wait_for_user(
372
+ self, question: str, input_type: str = "text", options: Optional[List[Dict]] = None
373
+ ) -> str:
374
+ """
375
+ Pause workflow execution and wait for user input.
376
+
377
+ On replay (even after worker crash), resumes from this point
378
+ with the user's response. This method enables human-in-the-loop
379
+ workflows by pausing execution and waiting for user interaction.
380
+
381
+ Args:
382
+ question: Question to ask the user
383
+ input_type: Type of input - "text", "approval", or "choice"
384
+ options: For approval/choice, list of option dicts with 'id' and 'label'
385
+
386
+ Returns:
387
+ User's response string
388
+
389
+ Raises:
390
+ WaitingForUserInputException: When no cached response exists (first call)
391
+
392
+ Example (text input):
393
+ ```python
394
+ city = await ctx.wait_for_user("Which city?")
395
+ ```
396
+
397
+ Example (approval):
398
+ ```python
399
+ decision = await ctx.wait_for_user(
400
+ "Approve this action?",
401
+ input_type="approval",
402
+ options=[
403
+ {"id": "approve", "label": "Approve"},
404
+ {"id": "reject", "label": "Reject"}
405
+ ]
406
+ )
407
+ ```
408
+
409
+ Example (choice):
410
+ ```python
411
+ model = await ctx.wait_for_user(
412
+ "Which model?",
413
+ input_type="choice",
414
+ options=[
415
+ {"id": "gpt4", "label": "GPT-4"},
416
+ {"id": "claude", "label": "Claude"}
417
+ ]
418
+ )
419
+ ```
420
+ """
421
+ from .exceptions import WaitingForUserInputException
422
+
423
+ # Generate unique step name for this user input request
424
+ # Using run_id ensures uniqueness across workflow execution
425
+ response_key = f"user_response:{self.run_id}"
426
+
427
+ # Check if we already have the user's response (replay scenario)
428
+ if self._workflow_entity.has_completed_step(response_key):
429
+ response = self._workflow_entity.get_completed_step(response_key)
430
+ self._logger.info("🔄 Replaying user response from checkpoint")
431
+ return response
432
+
433
+ # No response yet - pause execution
434
+ # Collect current workflow state for checkpoint
435
+ checkpoint_state = {}
436
+ if hasattr(self._workflow_entity, "_state") and self._workflow_entity._state is not None:
437
+ checkpoint_state = self._workflow_entity._state.get_state_snapshot()
438
+
439
+ self._logger.info(f"⏸️ Pausing workflow for user input: {question}")
440
+
441
+ raise WaitingForUserInputException(
442
+ question=question,
443
+ input_type=input_type,
444
+ options=options,
445
+ checkpoint_state=checkpoint_state,
446
+ )
447
+
448
+
449
+ # ============================================================================
450
+ # Helper functions for workflow execution
451
+ # ============================================================================
452
+
453
+
454
+ def _sanitize_for_json(obj: Any) -> Any:
455
+ """
456
+ Sanitize data for JSON serialization by removing or converting non-serializable objects.
457
+
458
+ Specifically handles:
459
+ - WorkflowContext objects (replaced with placeholder)
460
+ - Nested structures (recursively sanitized)
461
+
462
+ Args:
463
+ obj: Object to sanitize
464
+
465
+ Returns:
466
+ JSON-serializable version of the object
467
+ """
468
+ # Handle None, primitives
469
+ if obj is None or isinstance(obj, (str, int, float, bool)):
470
+ return obj
471
+
472
+ # Handle WorkflowContext - replace with placeholder
473
+ if isinstance(obj, WorkflowContext):
474
+ return "<WorkflowContext>"
475
+
476
+ # Handle tuples/lists - recursively sanitize
477
+ if isinstance(obj, (tuple, list)):
478
+ sanitized = [_sanitize_for_json(item) for item in obj]
479
+ return sanitized if isinstance(obj, list) else tuple(sanitized)
480
+
481
+ # Handle dicts - recursively sanitize values
482
+ if isinstance(obj, dict):
483
+ return {k: _sanitize_for_json(v) for k, v in obj.items()}
484
+
485
+ # For other objects, try to serialize or convert to string
486
+ try:
487
+ import json
488
+ json.dumps(obj)
489
+ return obj
490
+ except (TypeError, ValueError):
491
+ # Not JSON serializable, use string representation
492
+ return repr(obj)
493
+
494
+
495
+ # ============================================================================
496
+ # WorkflowEntity: Entity specialized for workflow execution state
497
+ # ============================================================================
498
+
499
+
500
+ class WorkflowEntity(Entity):
501
+ """
502
+ Entity specialized for workflow execution state.
503
+
504
+ Extends Entity with workflow-specific capabilities:
505
+ - Step tracking for replay and crash recovery
506
+ - State change tracking for debugging and audit (AI workflows)
507
+ - Completed step cache for efficient replay
508
+ - Automatic state persistence after workflow execution
509
+
510
+ Workflow state is persisted to the database after successful execution,
511
+ enabling crash recovery, replay, and cross-invocation state management.
512
+ The workflow decorator automatically calls _persist_state() to ensure
513
+ durability.
514
+ """
515
+
516
+ def __init__(
517
+ self,
518
+ run_id: str,
519
+ session_id: Optional[str] = None,
520
+ user_id: Optional[str] = None,
521
+ ):
522
+ """
523
+ Initialize workflow entity with memory scope.
524
+
525
+ Args:
526
+ run_id: Unique workflow run identifier
527
+ session_id: Session identifier for multi-turn conversations (optional)
528
+ user_id: User identifier for user-scoped memory (optional)
529
+
530
+ Memory Scope Priority:
531
+ - user_id present → key: user:{user_id}
532
+ - session_id present (and != run_id) → key: session:{session_id}
533
+ - else → key: run:{run_id}
534
+ """
535
+ # Determine entity key based on memory scope priority
536
+ if user_id:
537
+ entity_key = f"user:{user_id}"
538
+ memory_scope = "user"
539
+ elif session_id and session_id != run_id:
540
+ entity_key = f"session:{session_id}"
541
+ memory_scope = "session"
542
+ else:
543
+ entity_key = f"run:{run_id}"
544
+ memory_scope = "run"
545
+
546
+ # Initialize as entity with scoped key pattern
547
+ super().__init__(key=entity_key)
548
+
549
+ # Store run_id separately for tracking (even if key is session/user scoped)
550
+ self._run_id = run_id
551
+ self._memory_scope = memory_scope
552
+
553
+ # Step tracking for replay and recovery
554
+ self._step_events: list[Dict[str, Any]] = []
555
+ self._completed_steps: Dict[str, Any] = {}
556
+
557
+ # State change tracking for debugging/audit (AI workflows)
558
+ self._state_changes: list[Dict[str, Any]] = []
559
+
560
+ logger.debug(f"Created WorkflowEntity: run={run_id}, scope={memory_scope}, key={entity_key}")
561
+
562
+ @property
563
+ def run_id(self) -> str:
564
+ """Get run_id for this workflow execution."""
565
+ return self._run_id
566
+
567
+ def record_step_completion(
568
+ self, step_name: str, handler_name: str, input_data: Any, result: Any
569
+ ) -> None:
570
+ """
571
+ Record completed step for replay and recovery.
572
+
573
+ Args:
574
+ step_name: Unique step identifier
575
+ handler_name: Function handler name
576
+ input_data: Input data passed to function
577
+ result: Function result
578
+ """
579
+ # Sanitize input_data and result to ensure JSON serializability
580
+ # This removes WorkflowContext objects and other non-serializable types
581
+ sanitized_input = _sanitize_for_json(input_data)
582
+ sanitized_result = _sanitize_for_json(result)
583
+
584
+ self._step_events.append(
585
+ {
586
+ "step_name": step_name,
587
+ "handler_name": handler_name,
588
+ "input": sanitized_input,
589
+ "result": sanitized_result,
590
+ }
591
+ )
592
+ self._completed_steps[step_name] = result
593
+ logger.debug(f"Recorded step completion: {step_name}")
594
+
595
+ def get_completed_step(self, step_name: str) -> Optional[Any]:
596
+ """
597
+ Get result of completed step (for replay).
598
+
599
+ Args:
600
+ step_name: Step identifier
601
+
602
+ Returns:
603
+ Step result if completed, None otherwise
604
+ """
605
+ return self._completed_steps.get(step_name)
606
+
607
+ def has_completed_step(self, step_name: str) -> bool:
608
+ """Check if step has been completed."""
609
+ return step_name in self._completed_steps
610
+
611
+ def inject_user_response(self, response: str) -> None:
612
+ """
613
+ Inject user response as a completed step for workflow resume.
614
+
615
+ This method is called by the worker when resuming a paused workflow
616
+ with the user's response. It stores the response as if it was a
617
+ completed step, allowing wait_for_user() to retrieve it on replay.
618
+
619
+ Args:
620
+ response: User's response to inject
621
+
622
+ Example:
623
+ # Platform resumes workflow with user response
624
+ workflow_entity.inject_user_response("yes")
625
+ # On replay, wait_for_user() returns "yes" from cache
626
+ """
627
+ response_key = f"user_response:{self.run_id}"
628
+ self._completed_steps[response_key] = response
629
+ logger.info(f"Injected user response for {self.run_id}: {response}")
630
+
631
+ def get_agent_data(self, agent_name: str) -> Dict[str, Any]:
632
+ """
633
+ Get agent conversation data from workflow state.
634
+
635
+ Args:
636
+ agent_name: Name of the agent
637
+
638
+ Returns:
639
+ Dictionary containing agent conversation data (messages, metadata)
640
+ or empty dict if agent has no data yet
641
+
642
+ Example:
643
+ ```python
644
+ agent_data = workflow_entity.get_agent_data("ResearchAgent")
645
+ messages = agent_data.get("messages", [])
646
+ ```
647
+ """
648
+ return self.state.get(f"agent.{agent_name}", {})
649
+
650
+ def get_agent_messages(self, agent_name: str) -> list[Dict[str, Any]]:
651
+ """
652
+ Get agent messages from workflow state.
653
+
654
+ Args:
655
+ agent_name: Name of the agent
656
+
657
+ Returns:
658
+ List of message dictionaries
659
+
660
+ Example:
661
+ ```python
662
+ messages = workflow_entity.get_agent_messages("ResearchAgent")
663
+ for msg in messages:
664
+ print(f"{msg['role']}: {msg['content']}")
665
+ ```
666
+ """
667
+ agent_data = self.get_agent_data(agent_name)
668
+ return agent_data.get("messages", [])
669
+
670
+ def list_agents(self) -> list[str]:
671
+ """
672
+ List all agents with data in this workflow.
673
+
674
+ Returns:
675
+ List of agent names that have stored conversation data
676
+
677
+ Example:
678
+ ```python
679
+ agents = workflow_entity.list_agents()
680
+ # ['ResearchAgent', 'AnalysisAgent', 'SynthesisAgent']
681
+ ```
682
+ """
683
+ agents = []
684
+ for key in self.state._state.keys():
685
+ if key.startswith("agent."):
686
+ agents.append(key.replace("agent.", "", 1))
687
+ return agents
688
+
689
+ async def _persist_state(self) -> None:
690
+ """
691
+ Internal method to persist workflow state to entity storage.
692
+
693
+ This is prefixed with _ so it won't be wrapped by the entity method wrapper.
694
+ Called after workflow execution completes to ensure state is durable.
695
+ """
696
+ logger.info(f"🔍 DEBUG: _persist_state() CALLED for workflow {self.run_id}")
697
+
698
+ try:
699
+ from .entity import _get_state_adapter
700
+
701
+ logger.info(f"🔍 DEBUG: Getting state adapter...")
702
+ # Get the state adapter (must be in Worker context)
703
+ adapter = _get_state_adapter()
704
+ logger.info(f"🔍 DEBUG: Got state adapter: {type(adapter).__name__}")
705
+
706
+ logger.info(f"🔍 DEBUG: Getting state snapshot...")
707
+ # Get current state snapshot
708
+ state_dict = self.state.get_state_snapshot()
709
+ logger.info(f"🔍 DEBUG: State snapshot has {len(state_dict)} keys: {list(state_dict.keys())}")
710
+
711
+ logger.info(f"🔍 DEBUG: Loading current version for optimistic locking...")
712
+ # Load current version (for optimistic locking)
713
+ _, current_version = await adapter.load_with_version(self._entity_type, self._key)
714
+ logger.info(f"🔍 DEBUG: Current version: {current_version}")
715
+
716
+ logger.info(f"🔍 DEBUG: Saving state to database...")
717
+ # Save state with version check
718
+ new_version = await adapter.save_state(
719
+ self._entity_type, self._key, state_dict, current_version
720
+ )
721
+
722
+ logger.info(
723
+ f"✅ SUCCESS: Persisted WorkflowEntity state for {self.run_id} "
724
+ f"(version {current_version} -> {new_version}, {len(state_dict)} keys)"
725
+ )
726
+ except Exception as e:
727
+ logger.error(
728
+ f"❌ ERROR: Failed to persist workflow state for {self.run_id}: {e}",
729
+ exc_info=True
730
+ )
731
+ # Re-raise to let caller handle
732
+ raise
733
+
734
+ @property
735
+ def state(self) -> "WorkflowState":
736
+ """
737
+ Get workflow state with change tracking.
738
+
739
+ Returns WorkflowState which tracks all state mutations
740
+ for debugging and replay of AI workflows.
741
+ """
742
+ if self._state is None:
743
+ # Initialize with empty state dict - will be populated by entity system
744
+ self._state = WorkflowState({}, self)
745
+ return self._state
746
+
747
+
748
+ class WorkflowState(EntityState):
749
+ """
750
+ State interface for WorkflowEntity with change tracking.
751
+
752
+ Extends EntityState to track all state mutations for:
753
+ - AI workflow debugging
754
+ - Audit trail
755
+ - Replay capabilities
756
+ """
757
+
758
+ def __init__(self, state_dict: Dict[str, Any], workflow_entity: WorkflowEntity):
759
+ """
760
+ Initialize workflow state.
761
+
762
+ Args:
763
+ state_dict: Dictionary to use for state storage
764
+ workflow_entity: Parent workflow entity for tracking
765
+ """
766
+ super().__init__(state_dict)
767
+ self._workflow_entity = workflow_entity
768
+ self._checkpoint_callback: Optional[Callable[[str, dict], None]] = None
769
+
770
+ def _set_checkpoint_callback(self, callback: Callable[[str, dict], None]) -> None:
771
+ """
772
+ Set the checkpoint callback for real-time state change streaming.
773
+
774
+ Args:
775
+ callback: Function to call when state changes
776
+ """
777
+ self._checkpoint_callback = callback
778
+
779
+ def set(self, key: str, value: Any) -> None:
780
+ """Set value and track change."""
781
+ super().set(key, value)
782
+ # Track change for debugging/audit
783
+ import time
784
+
785
+ change_record = {"key": key, "value": value, "timestamp": time.time(), "deleted": False}
786
+ self._workflow_entity._state_changes.append(change_record)
787
+
788
+ # Emit checkpoint for real-time state streaming
789
+ if self._checkpoint_callback:
790
+ self._checkpoint_callback(
791
+ "workflow.state.changed", {"key": key, "value": value, "operation": "set"}
792
+ )
793
+
794
+ def delete(self, key: str) -> None:
795
+ """Delete key and track change."""
796
+ super().delete(key)
797
+ # Track deletion
798
+ import time
799
+
800
+ change_record = {"key": key, "value": None, "timestamp": time.time(), "deleted": True}
801
+ self._workflow_entity._state_changes.append(change_record)
802
+
803
+ # Emit checkpoint for real-time state streaming
804
+ if self._checkpoint_callback:
805
+ self._checkpoint_callback("workflow.state.changed", {"key": key, "operation": "delete"})
806
+
807
+ def clear(self) -> None:
808
+ """Clear all state and track change."""
809
+ super().clear()
810
+ # Track clear operation
811
+ import time
812
+
813
+ change_record = {
814
+ "key": "__clear__",
815
+ "value": None,
816
+ "timestamp": time.time(),
817
+ "deleted": True,
818
+ }
819
+ self._workflow_entity._state_changes.append(change_record)
820
+
821
+ # Emit checkpoint for real-time state streaming
822
+ if self._checkpoint_callback:
823
+ self._checkpoint_callback("workflow.state.changed", {"operation": "clear"})
824
+
825
+ def has_changes(self) -> bool:
826
+ """Check if any state changes have been tracked."""
827
+ return len(self._workflow_entity._state_changes) > 0
828
+
829
+ def get_state_snapshot(self) -> Dict[str, Any]:
830
+ """Get current state as a snapshot dictionary."""
831
+ return dict(self._state)
832
+
833
+
834
+ class WorkflowRegistry:
835
+ """Registry for workflow handlers."""
836
+
837
+ @staticmethod
838
+ def register(config: WorkflowConfig) -> None:
839
+ """
840
+ Register a workflow handler.
841
+
842
+ Raises:
843
+ ValueError: If a workflow with this name is already registered
844
+ """
845
+ if config.name in _WORKFLOW_REGISTRY:
846
+ existing_workflow = _WORKFLOW_REGISTRY[config.name]
847
+ logger.error(
848
+ f"Workflow name collision detected: '{config.name}'\n"
849
+ f" First defined in: {existing_workflow.handler.__module__}\n"
850
+ f" Also defined in: {config.handler.__module__}\n"
851
+ f" This is a bug - workflows must have unique names."
852
+ )
853
+ raise ValueError(
854
+ f"Workflow '{config.name}' is already registered. "
855
+ f"Use @workflow(name='unique_name') to specify a different name."
856
+ )
857
+
858
+ _WORKFLOW_REGISTRY[config.name] = config
859
+ logger.debug(f"Registered workflow '{config.name}'")
860
+
861
+ @staticmethod
862
+ def get(name: str) -> Optional[WorkflowConfig]:
863
+ """Get workflow configuration by name."""
864
+ return _WORKFLOW_REGISTRY.get(name)
865
+
866
+ @staticmethod
867
+ def all() -> Dict[str, WorkflowConfig]:
868
+ """Get all registered workflows."""
869
+ return _WORKFLOW_REGISTRY.copy()
870
+
871
+ @staticmethod
872
+ def list_names() -> list[str]:
873
+ """List all registered workflow names."""
874
+ return list(_WORKFLOW_REGISTRY.keys())
875
+
876
+ @staticmethod
877
+ def clear() -> None:
878
+ """Clear all registered workflows."""
879
+ _WORKFLOW_REGISTRY.clear()
880
+
881
+
882
+ def workflow(
883
+ _func: Optional[Callable[..., Any]] = None,
884
+ *,
885
+ name: Optional[str] = None,
886
+ chat: bool = False,
887
+ ) -> Callable[..., Any]:
888
+ """
889
+ Decorator to mark a function as an AGNT5 durable workflow.
890
+
891
+ Workflows use WorkflowEntity for state management and WorkflowContext
892
+ for orchestration. State changes are automatically tracked for replay.
893
+
894
+ Args:
895
+ name: Custom workflow name (default: function's __name__)
896
+ chat: Enable chat mode for multi-turn conversation workflows (default: False)
897
+
898
+ Example (standard workflow):
899
+ @workflow
900
+ async def process_order(ctx: WorkflowContext, order_id: str) -> dict:
901
+ # Durable state - survives crashes
902
+ ctx.state.set("status", "processing")
903
+ ctx.state.set("order_id", order_id)
904
+
905
+ # Validate order
906
+ order = await ctx.task(validate_order, input={"order_id": order_id})
907
+
908
+ # Process payment (checkpointed - won't re-execute on crash)
909
+ payment = await ctx.step("payment", process_payment(order["total"]))
910
+
911
+ # Fulfill order
912
+ await ctx.task(ship_order, input={"order_id": order_id})
913
+
914
+ ctx.state.set("status", "completed")
915
+ return {"status": ctx.state.get("status")}
916
+
917
+ Example (chat workflow):
918
+ @workflow(chat=True)
919
+ async def customer_support(ctx: WorkflowContext, message: str) -> dict:
920
+ # Initialize conversation state
921
+ if not ctx.state.get("messages"):
922
+ ctx.state.set("messages", [])
923
+
924
+ # Add user message
925
+ messages = ctx.state.get("messages")
926
+ messages.append({"role": "user", "content": message})
927
+ ctx.state.set("messages", messages)
928
+
929
+ # Generate AI response
930
+ response = await ctx.task(generate_response, messages=messages)
931
+
932
+ # Add assistant response
933
+ messages.append({"role": "assistant", "content": response})
934
+ ctx.state.set("messages", messages)
935
+
936
+ return {"response": response, "turn_count": len(messages) // 2}
937
+ """
938
+
939
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
940
+ # Get workflow name
941
+ workflow_name = name or func.__name__
942
+
943
+ # Validate function signature
944
+ sig = inspect.signature(func)
945
+ params = list(sig.parameters.values())
946
+
947
+ if not params or params[0].name != "ctx":
948
+ raise ValueError(
949
+ f"Workflow '{workflow_name}' must have 'ctx: WorkflowContext' as first parameter"
950
+ )
951
+
952
+ # Convert sync to async if needed
953
+ if inspect.iscoroutinefunction(func):
954
+ handler_func = cast(HandlerFunc, func)
955
+ else:
956
+ # Wrap sync function in async
957
+ @functools.wraps(func)
958
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
959
+ return func(*args, **kwargs)
960
+
961
+ handler_func = cast(HandlerFunc, async_wrapper)
962
+
963
+ # Extract schemas from type hints
964
+ input_schema, output_schema = extract_function_schemas(func)
965
+
966
+ # Extract metadata (description, etc.)
967
+ metadata = extract_function_metadata(func)
968
+
969
+ # Add chat metadata if chat mode is enabled
970
+ if chat:
971
+ metadata["chat"] = "true"
972
+
973
+ # Register workflow
974
+ config = WorkflowConfig(
975
+ name=workflow_name,
976
+ handler=handler_func,
977
+ input_schema=input_schema,
978
+ output_schema=output_schema,
979
+ metadata=metadata,
980
+ )
981
+ WorkflowRegistry.register(config)
982
+
983
+ # Create wrapper that provides context
984
+ @functools.wraps(func)
985
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
986
+ # Create WorkflowEntity and WorkflowContext if not provided
987
+ if not args or not isinstance(args[0], WorkflowContext):
988
+ # Auto-create workflow entity and context for direct workflow calls
989
+ run_id = f"workflow-{uuid.uuid4().hex[:8]}"
990
+
991
+ # Create WorkflowEntity to manage state
992
+ workflow_entity = WorkflowEntity(run_id=run_id)
993
+
994
+ # Create WorkflowContext that wraps the entity
995
+ ctx = WorkflowContext(
996
+ workflow_entity=workflow_entity,
997
+ run_id=run_id,
998
+ )
999
+
1000
+ # Set context in task-local storage for automatic propagation
1001
+ token = set_current_context(ctx)
1002
+ try:
1003
+ # Execute workflow
1004
+ result = await handler_func(ctx, *args, **kwargs)
1005
+
1006
+ # Persist workflow state after successful execution
1007
+ try:
1008
+ await workflow_entity._persist_state()
1009
+ except Exception as e:
1010
+ logger.error(f"Failed to persist workflow state (non-fatal): {e}", exc_info=True)
1011
+ # Don't fail the workflow - persistence failure shouldn't break execution
1012
+
1013
+ return result
1014
+ finally:
1015
+ # Always reset context to prevent leakage
1016
+ from .context import _current_context
1017
+
1018
+ _current_context.reset(token)
1019
+ else:
1020
+ # WorkflowContext provided - use it and set in contextvar
1021
+ ctx = args[0]
1022
+ token = set_current_context(ctx)
1023
+ try:
1024
+ result = await handler_func(*args, **kwargs)
1025
+
1026
+ # Persist workflow state after successful execution
1027
+ try:
1028
+ await ctx._workflow_entity._persist_state()
1029
+ except Exception as e:
1030
+ logger.error(f"Failed to persist workflow state (non-fatal): {e}", exc_info=True)
1031
+ # Don't fail the workflow - persistence failure shouldn't break execution
1032
+
1033
+ return result
1034
+ finally:
1035
+ # Always reset context to prevent leakage
1036
+ from .context import _current_context
1037
+
1038
+ _current_context.reset(token)
1039
+
1040
+ # Store config on wrapper for introspection
1041
+ wrapper._agnt5_config = config # type: ignore
1042
+ return wrapper
1043
+
1044
+ # Handle both @workflow and @workflow(...) syntax
1045
+ if _func is None:
1046
+ return decorator
1047
+ else:
1048
+ return decorator(_func)