agent-framework-declarative 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,650 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """WorkflowState manages PowerFx variables during declarative workflow execution.
4
+
5
+ This module provides state management for declarative workflows, handling:
6
+ - Workflow inputs (read-only)
7
+ - Turn-scoped variables
8
+ - Workflow outputs
9
+ - Agent results and context
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import uuid
16
+ from collections.abc import Mapping
17
+ from typing import Any, cast
18
+
19
+ try:
20
+ from powerfx import Engine
21
+
22
+ _powerfx_engine: Engine | None = Engine()
23
+ except (ImportError, RuntimeError):
24
+ # ImportError: powerfx package not installed
25
+ # RuntimeError: .NET runtime not available or misconfigured
26
+ _powerfx_engine = None
27
+
28
+ logger = logging.getLogger("agent_framework.declarative")
29
+
30
+
31
+ class WorkflowState:
32
+ """Manages variables and state during declarative workflow execution.
33
+
34
+ WorkflowState provides a unified interface for:
35
+
36
+ - Reading workflow inputs (immutable after initialization)
37
+ - Managing Local-scoped variables that persist across actions
38
+ - Storing agent results and making them available to subsequent actions
39
+ - Evaluating PowerFx expressions with the current state as context
40
+
41
+ The state is organized into namespaces that mirror the .NET implementation:
42
+
43
+ - Workflow.Inputs: Initial inputs to the workflow
44
+ - Workflow.Outputs: Values to be returned from the workflow
45
+ - Local: Variables that persist within the current workflow turn
46
+ - System: System-level variables (ConversationId, LastMessage, etc.)
47
+ - Agent: Results from the most recent agent invocation
48
+ - Conversation: Conversation history and messages
49
+
50
+ Examples:
51
+ .. code-block:: python
52
+
53
+ from agent_framework_declarative import WorkflowState
54
+
55
+ # Initialize with inputs
56
+ state = WorkflowState(inputs={"query": "Hello", "user_id": "123"})
57
+
58
+ # Access inputs (read-only)
59
+ query = state.get("Workflow.Inputs.query") # "Hello"
60
+
61
+ # Set Local-scoped variables
62
+ state.set("Local.results", [])
63
+ state.append("Local.results", "item1")
64
+ state.append("Local.results", "item2")
65
+
66
+ # Set workflow outputs
67
+ state.set("Workflow.Outputs.response", "Completed")
68
+
69
+ .. code-block:: python
70
+
71
+ from agent_framework_declarative import WorkflowState
72
+
73
+ # PowerFx expression evaluation
74
+ state = WorkflowState(inputs={"name": "World"})
75
+ result = state.eval("=Concat('Hello ', Workflow.Inputs.name)")
76
+ # result: "Hello World"
77
+
78
+ # Non-PowerFx strings are returned as-is
79
+ plain = state.eval("Hello World")
80
+ # plain: "Hello World"
81
+
82
+ .. code-block:: python
83
+
84
+ from agent_framework_declarative import WorkflowState
85
+
86
+ # Working with agent results
87
+ state = WorkflowState()
88
+ state.set_agent_result(
89
+ text="The answer is 42.",
90
+ messages=[],
91
+ tool_calls=[],
92
+ )
93
+
94
+ # Access agent result in subsequent actions
95
+ response = state.get("Agent.text") # "The answer is 42."
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ inputs: Mapping[str, Any] | None = None,
101
+ ) -> None:
102
+ """Initialize workflow state with optional inputs.
103
+
104
+ Args:
105
+ inputs: Initial inputs to the workflow. These become available
106
+ as Workflow.Inputs.* and are immutable after initialization.
107
+ """
108
+ self._inputs: dict[str, Any] = dict(inputs) if inputs else {}
109
+ self._local: dict[str, Any] = {}
110
+ self._outputs: dict[str, Any] = {}
111
+ conversation_id = str(uuid.uuid4())
112
+ self._system: dict[str, Any] = {
113
+ "ConversationId": conversation_id,
114
+ "LastMessage": {"Text": "", "Id": ""},
115
+ "LastMessageText": "",
116
+ "LastMessageId": "",
117
+ "conversations": {
118
+ conversation_id: {"id": conversation_id, "messages": []},
119
+ },
120
+ }
121
+ self._agent: dict[str, Any] = {}
122
+ self._conversation: dict[str, Any] = {
123
+ "messages": [],
124
+ "history": [],
125
+ }
126
+ self._custom: dict[str, Any] = {}
127
+
128
+ @property
129
+ def inputs(self) -> Mapping[str, Any]:
130
+ """Get the workflow inputs (read-only)."""
131
+ return self._inputs
132
+
133
+ @property
134
+ def outputs(self) -> dict[str, Any]:
135
+ """Get the workflow outputs."""
136
+ return self._outputs
137
+
138
+ @property
139
+ def local(self) -> dict[str, Any]:
140
+ """Get the Local-scoped variables."""
141
+ return self._local
142
+
143
+ @property
144
+ def system(self) -> dict[str, Any]:
145
+ """Get the System-scoped variables."""
146
+ return self._system
147
+
148
+ @property
149
+ def agent(self) -> dict[str, Any]:
150
+ """Get the most recent agent result."""
151
+ return self._agent
152
+
153
+ @property
154
+ def conversation(self) -> dict[str, Any]:
155
+ """Get the conversation state."""
156
+ return self._conversation
157
+
158
+ def get(self, path: str, default: Any = None) -> Any:
159
+ """Get a value from the state using a dot-notated path.
160
+
161
+ Args:
162
+ path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
163
+ default: Default value if path doesn't exist
164
+
165
+ Returns:
166
+ The value at the path, or default if not found
167
+ """
168
+ parts = path.split(".")
169
+ if not parts:
170
+ return default
171
+
172
+ namespace = parts[0]
173
+ remaining = parts[1:]
174
+
175
+ # Handle Workflow.Inputs and Workflow.Outputs specially
176
+ if namespace == "Workflow" and remaining:
177
+ sub_namespace = remaining[0]
178
+ remaining = remaining[1:]
179
+ if sub_namespace == "Inputs":
180
+ obj: Any = self._inputs
181
+ elif sub_namespace == "Outputs":
182
+ obj = self._outputs
183
+ else:
184
+ return default
185
+ elif namespace == "Local":
186
+ obj = self._local
187
+ elif namespace == "System":
188
+ obj = self._system
189
+ elif namespace == "Agent":
190
+ obj = self._agent
191
+ elif namespace == "Conversation":
192
+ obj = self._conversation
193
+ else:
194
+ # Try custom namespace
195
+ obj = self._custom.get(namespace, default)
196
+ if obj is default:
197
+ return default
198
+
199
+ # Navigate the remaining path
200
+ for part in remaining:
201
+ if isinstance(obj, dict):
202
+ obj_dict: dict[str, Any] = cast(dict[str, Any], obj)
203
+ obj = obj_dict.get(part, default)
204
+ if obj is default:
205
+ return default
206
+ elif hasattr(obj, part):
207
+ obj = getattr(obj, part)
208
+ else:
209
+ return default
210
+
211
+ return obj
212
+
213
+ def set(self, path: str, value: Any) -> None:
214
+ """Set a value in the state using a dot-notated path.
215
+
216
+ Args:
217
+ path: Dot-notated path like 'Local.results' or 'Workflow.Outputs.response'
218
+ value: The value to set
219
+
220
+ Raises:
221
+ ValueError: If attempting to set Workflow.Inputs (which is read-only)
222
+ """
223
+ parts = path.split(".")
224
+ if not parts:
225
+ return
226
+
227
+ namespace = parts[0]
228
+ remaining = parts[1:]
229
+
230
+ # Handle Workflow.Inputs and Workflow.Outputs specially
231
+ if namespace == "Workflow":
232
+ if not remaining:
233
+ raise ValueError("Cannot set 'Workflow' directly; use 'Workflow.Outputs.*'")
234
+ sub_namespace = remaining[0]
235
+ remaining = remaining[1:]
236
+ if sub_namespace == "Inputs":
237
+ raise ValueError("Cannot modify Workflow.Inputs - they are read-only")
238
+ if sub_namespace == "Outputs":
239
+ target = self._outputs
240
+ else:
241
+ raise ValueError(f"Unknown Workflow namespace: {sub_namespace}")
242
+ elif namespace == "Local":
243
+ target = self._local
244
+ elif namespace == "System":
245
+ target = self._system
246
+ elif namespace == "Agent":
247
+ target = self._agent
248
+ elif namespace == "Conversation":
249
+ target = self._conversation
250
+ else:
251
+ # Create or use custom namespace
252
+ if namespace not in self._custom:
253
+ self._custom[namespace] = {}
254
+ target = self._custom[namespace]
255
+
256
+ # Navigate to the parent and set the value
257
+ if not remaining:
258
+ # Setting the namespace root itself - this shouldn't happen normally
259
+ raise ValueError(f"Cannot replace entire namespace '{namespace}'")
260
+
261
+ # Navigate to parent, creating dicts as needed
262
+ for part in remaining[:-1]:
263
+ if part not in target:
264
+ target[part] = {}
265
+ target = target[part]
266
+
267
+ # Set the final value
268
+ target[remaining[-1]] = value
269
+
270
+ def append(self, path: str, value: Any) -> None:
271
+ """Append a value to a list at the specified path.
272
+
273
+ If the path doesn't exist, creates a new list with the value.
274
+ If the path exists but isn't a list, raises ValueError.
275
+
276
+ Args:
277
+ path: Dot-notated path to a list
278
+ value: The value to append
279
+
280
+ Raises:
281
+ ValueError: If the existing value is not a list
282
+ """
283
+ existing = self.get(path)
284
+ if existing is None:
285
+ self.set(path, [value])
286
+ elif isinstance(existing, list):
287
+ existing_list = cast(list[Any], existing)
288
+ existing_list.append(value)
289
+ self.set(path, existing_list)
290
+ else:
291
+ raise ValueError(f"Cannot append to non-list at path '{path}'")
292
+
293
+ def set_agent_result(
294
+ self,
295
+ text: str | None = None,
296
+ messages: list[Any] | None = None,
297
+ tool_calls: list[Any] | None = None,
298
+ **kwargs: Any,
299
+ ) -> None:
300
+ """Set the result from the most recent agent invocation.
301
+
302
+ This updates the 'agent' namespace with the agent's response,
303
+ making it available to subsequent actions via agent.text, agent.messages, etc.
304
+
305
+ Args:
306
+ text: The text content of the agent's response
307
+ messages: The messages from the agent
308
+ tool_calls: Any tool calls made by the agent
309
+ **kwargs: Additional result data
310
+ """
311
+ self._agent = {
312
+ "text": text,
313
+ "messages": messages or [],
314
+ "toolCalls": tool_calls or [],
315
+ **kwargs,
316
+ }
317
+
318
+ def add_conversation_message(self, message: Any) -> None:
319
+ """Add a message to the conversation history.
320
+
321
+ Args:
322
+ message: The message to add (typically a Message or similar)
323
+ """
324
+ self._conversation["messages"].append(message)
325
+ self._conversation["history"].append(message)
326
+
327
+ def to_powerfx_symbols(self) -> dict[str, Any]:
328
+ """Convert the current state to a PowerFx symbols dictionary.
329
+
330
+ Returns:
331
+ A dictionary suitable for passing to PowerFx Engine.eval()
332
+ """
333
+ symbols = {
334
+ "Workflow": {
335
+ "Inputs": dict(self._inputs),
336
+ "Outputs": dict(self._outputs),
337
+ },
338
+ "Local": dict(self._local),
339
+ "System": dict(self._system),
340
+ "Agent": dict(self._agent),
341
+ "Conversation": dict(self._conversation),
342
+ # Also expose inputs at top level for backward compatibility with =inputs.X syntax
343
+ "inputs": dict(self._inputs),
344
+ **self._custom,
345
+ }
346
+ # Debug log the Local symbols to help diagnose type issues
347
+ if self._local:
348
+ for key, value in self._local.items():
349
+ logger.debug(
350
+ f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
351
+ f"value_preview={str(value)[:100] if value else None}"
352
+ )
353
+ return symbols
354
+
355
+ def eval(self, expression: str) -> Any:
356
+ """Evaluate a PowerFx expression with the current state.
357
+
358
+ Expressions starting with '=' are evaluated as PowerFx.
359
+ Other strings are returned as-is (after variable interpolation if applicable).
360
+
361
+ Args:
362
+ expression: The expression to evaluate
363
+
364
+ Returns:
365
+ The evaluated result, or the original expression if not a PowerFx expression
366
+ """
367
+ if not expression:
368
+ return expression
369
+
370
+ if not expression.startswith("="):
371
+ return expression
372
+
373
+ # Strip the leading '=' for evaluation
374
+ formula = expression[1:]
375
+
376
+ if _powerfx_engine is not None:
377
+ # Try PowerFx evaluation first
378
+ try:
379
+ symbols = self.to_powerfx_symbols()
380
+ return _powerfx_engine.eval(formula, symbols=symbols)
381
+ except Exception as exc:
382
+ logger.warning(f"PowerFx evaluation failed for '{expression[:50]}': {exc}")
383
+ # Fall through to simple evaluation
384
+
385
+ # Fallback: Simple expression evaluation using custom functions
386
+ return self._eval_simple(formula)
387
+
388
+ def _eval_simple(self, formula: str) -> Any:
389
+ """Simple expression evaluation when PowerFx is not available.
390
+
391
+ Supports:
392
+ - Variable references: Local.X, System.X, Workflow.Inputs.X
393
+ - Simple function calls: IsBlank(x), Find(a, b), etc.
394
+ - Simple comparisons: x < 4, x = "value"
395
+ - Logical operators: And, Or, Not, ||, !
396
+ - Negation: !expression
397
+
398
+ Args:
399
+ formula: The formula to evaluate (without leading '=')
400
+
401
+ Returns:
402
+ The evaluated result
403
+ """
404
+ from ._powerfx_functions import CUSTOM_FUNCTIONS
405
+
406
+ formula = formula.strip()
407
+
408
+ # Handle negation prefix
409
+ if formula.startswith("!"):
410
+ inner = formula[1:].strip()
411
+ result = self._eval_simple(inner)
412
+ return not bool(result)
413
+
414
+ # Handle Not() function
415
+ if formula.startswith("Not(") and formula.endswith(")"):
416
+ inner = formula[4:-1].strip()
417
+ result = self._eval_simple(inner)
418
+ return not bool(result)
419
+
420
+ # Handle function calls
421
+ for func_name, func in CUSTOM_FUNCTIONS.items():
422
+ if formula.startswith(f"{func_name}(") and formula.endswith(")"):
423
+ args_str = formula[len(func_name) + 1 : -1]
424
+ # Simple argument parsing (doesn't handle nested calls well)
425
+ args = self._parse_function_args(args_str)
426
+ evaluated_args = [self._eval_simple(arg) if isinstance(arg, str) else arg for arg in args]
427
+ try:
428
+ return func(*evaluated_args)
429
+ except Exception as e:
430
+ logger.warning(f"Function {func_name} failed: {e}")
431
+ return formula
432
+
433
+ # Handle And operator
434
+ if " And " in formula:
435
+ parts = formula.split(" And ", 1)
436
+ left = self._eval_simple(parts[0])
437
+ right = self._eval_simple(parts[1])
438
+ return bool(left) and bool(right)
439
+
440
+ # Handle Or operator (||)
441
+ if " || " in formula or " Or " in formula:
442
+ parts = formula.split(" || ", 1) if " || " in formula else formula.split(" Or ", 1)
443
+ left = self._eval_simple(parts[0])
444
+ right = self._eval_simple(parts[1])
445
+ return bool(left) or bool(right)
446
+
447
+ # Handle comparison operators
448
+ for op in [" < ", " > ", " <= ", " >= ", " <> ", " = "]:
449
+ if op in formula:
450
+ parts = formula.split(op, 1)
451
+ left = self._eval_simple(parts[0].strip())
452
+ right = self._eval_simple(parts[1].strip())
453
+ if op == " < ":
454
+ return left < right
455
+ if op == " > ":
456
+ return left > right
457
+ if op == " <= ":
458
+ return left <= right
459
+ if op == " >= ":
460
+ return left >= right
461
+ if op == " <> ":
462
+ return left != right
463
+ if op == " = ":
464
+ return left == right
465
+
466
+ # Handle arithmetic operators
467
+ if " + " in formula:
468
+ parts = formula.split(" + ", 1)
469
+ left = self._eval_simple(parts[0].strip())
470
+ right = self._eval_simple(parts[1].strip())
471
+ # Treat None as 0 for arithmetic (PowerFx behavior)
472
+ if left is None:
473
+ left = 0
474
+ if right is None:
475
+ right = 0
476
+ # Try numeric addition first, fall back to string concat
477
+ try:
478
+ return float(left) + float(right)
479
+ except (ValueError, TypeError):
480
+ return str(left) + str(right)
481
+
482
+ if " - " in formula:
483
+ parts = formula.split(" - ", 1)
484
+ left = self._eval_simple(parts[0].strip())
485
+ right = self._eval_simple(parts[1].strip())
486
+ # Treat None as 0 for arithmetic (PowerFx behavior)
487
+ if left is None:
488
+ left = 0
489
+ if right is None:
490
+ right = 0
491
+ try:
492
+ return float(left) - float(right)
493
+ except (ValueError, TypeError):
494
+ return formula
495
+
496
+ # Handle multiplication
497
+ if " * " in formula:
498
+ parts = formula.split(" * ", 1)
499
+ left = self._eval_simple(parts[0].strip())
500
+ right = self._eval_simple(parts[1].strip())
501
+ # Treat None as 0 for arithmetic (PowerFx behavior)
502
+ if left is None:
503
+ left = 0
504
+ if right is None:
505
+ right = 0
506
+ try:
507
+ return float(left) * float(right)
508
+ except (ValueError, TypeError):
509
+ return formula
510
+
511
+ # Handle division with div-by-zero protection
512
+ if " / " in formula:
513
+ parts = formula.split(" / ", 1)
514
+ left = self._eval_simple(parts[0].strip())
515
+ right = self._eval_simple(parts[1].strip())
516
+ # Treat None as 0 for arithmetic (PowerFx behavior)
517
+ if left is None:
518
+ left = 0
519
+ if right is None:
520
+ right = 0
521
+ try:
522
+ right_float = float(right)
523
+ if right_float == 0:
524
+ # PowerFx returns Error for division by zero; we return None (Blank)
525
+ logger.warning(f"Division by zero in expression: {formula}")
526
+ return None
527
+ return float(left) / right_float
528
+ except (ValueError, TypeError):
529
+ return formula
530
+
531
+ # Handle string literals
532
+ if (formula.startswith('"') and formula.endswith('"')) or (formula.startswith("'") and formula.endswith("'")):
533
+ return formula[1:-1]
534
+
535
+ # Handle numeric literals
536
+ try:
537
+ if "." in formula:
538
+ return float(formula)
539
+ return int(formula)
540
+ except ValueError:
541
+ pass
542
+
543
+ # Handle boolean literals
544
+ if formula.lower() == "true":
545
+ return True
546
+ if formula.lower() == "false":
547
+ return False
548
+
549
+ # Handle variable references
550
+ if "." in formula:
551
+ # For known namespaces, return None if not found (PowerFx semantics)
552
+ # rather than the formula string
553
+ if formula.startswith(("Local.", "Workflow.", "Agent.", "Conversation.", "System.")):
554
+ return self.get(formula)
555
+ not_found = object()
556
+ value = self.get(formula, default=not_found)
557
+ if value is not not_found:
558
+ return value
559
+
560
+ # Return the formula as-is if we can't evaluate it
561
+ return formula
562
+
563
+ def _parse_function_args(self, args_str: str) -> list[str]:
564
+ """Parse function arguments, handling nested parentheses and strings.
565
+
566
+ Args:
567
+ args_str: The argument string (without outer parentheses)
568
+
569
+ Returns:
570
+ List of argument strings
571
+ """
572
+ args: list[str] = []
573
+ current = ""
574
+ depth = 0
575
+ in_string = False
576
+ string_char = None
577
+
578
+ for char in args_str:
579
+ if char in ('"', "'") and not in_string:
580
+ in_string = True
581
+ string_char = char
582
+ current += char
583
+ elif char == string_char and in_string:
584
+ in_string = False
585
+ string_char = None
586
+ current += char
587
+ elif char == "(" and not in_string:
588
+ depth += 1
589
+ current += char
590
+ elif char == ")" and not in_string:
591
+ depth -= 1
592
+ current += char
593
+ elif char == "," and depth == 0 and not in_string:
594
+ args.append(current.strip())
595
+ current = ""
596
+ else:
597
+ current += char
598
+
599
+ if current.strip():
600
+ args.append(current.strip())
601
+
602
+ return args
603
+
604
+ def eval_if_expression(self, value: Any) -> Any:
605
+ """Evaluate a value if it's a PowerFx expression, otherwise return as-is.
606
+
607
+ This is a convenience method that handles both expressions and literals.
608
+
609
+ Args:
610
+ value: A value that may or may not be a PowerFx expression
611
+
612
+ Returns:
613
+ The evaluated result if it's an expression, or the original value
614
+ """
615
+ if isinstance(value, str):
616
+ return self.eval(value)
617
+ if isinstance(value, dict):
618
+ return {str(k): self.eval_if_expression(v) for k, v in value.items()} # type: ignore[reportUnknownVariableType]
619
+ if isinstance(value, list):
620
+ return [self.eval_if_expression(item) for item in value] # type: ignore[reportUnknownVariableType]
621
+ return value
622
+
623
+ def reset_local(self) -> None:
624
+ """Reset Local-scoped variables for a new turn.
625
+
626
+ This clears the Local namespace while preserving other state.
627
+ """
628
+ self._local.clear()
629
+
630
+ def reset_agent(self) -> None:
631
+ """Reset the agent result for a new agent invocation."""
632
+ self._agent.clear()
633
+
634
+ def clone(self) -> WorkflowState:
635
+ """Create a shallow copy of the state.
636
+
637
+ Returns:
638
+ A new WorkflowState with copied data
639
+ """
640
+ import copy
641
+
642
+ new_state = WorkflowState()
643
+ new_state._inputs = copy.copy(self._inputs)
644
+ new_state._local = copy.copy(self._local)
645
+ new_state._system = copy.copy(self._system)
646
+ new_state._outputs = copy.copy(self._outputs)
647
+ new_state._agent = copy.copy(self._agent)
648
+ new_state._conversation = copy.copy(self._conversation)
649
+ new_state._custom = copy.copy(self._custom)
650
+ return new_state
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-framework-declarative
3
+ Version: 1.0.0
4
+ Summary: Declarative specification support for Microsoft Agent Framework.
5
+ Author-email: Microsoft <af-support@microsoft.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ License-File: LICENSE
18
+ Requires-Dist: agent-framework-core>=1.9.0,<2
19
+ Requires-Dist: httpx>=0.27,<1
20
+ Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
21
+ Requires-Dist: pyyaml>=6.0,<7.0
22
+ Project-URL: homepage, https://aka.ms/agent-framework
23
+ Project-URL: issues, https://github.com/microsoft/agent-framework/issues
24
+ Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
25
+ Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
26
+
27
+ # Get Started with Microsoft Agent Framework Declarative
28
+
29
+ Please install this package via pip:
30
+
31
+ ```bash
32
+ pip install agent-framework-declarative
33
+ ```
34
+
35
+ ## Release stage
36
+
37
+ This package ships at two different stability levels:
38
+
39
+ - **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
40
+ `_workflows` surface) are **stable**.
41
+ - **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
42
+ `DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
43
+ **experimental** and may change or be removed in future versions without notice.
44
+ Using any of these symbols emits an `ExperimentalWarning` on first use.
45
+
46
+ ## Declarative features
47
+
48
+ The declarative packages provides support for building agents based on a declarative yaml specification.
49
+