Python: Add CreateConversationExecutor, fix input routing, remove unused handler layer (#4159)

* Fixed declarative deep research sample

* Small fix

* Resolved comment

* Add CreateConversationExecutor, fix input routing, remove unused handler layer

* Address Copilot feedback

* Fix System.ConversationId

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2026-02-24 10:59:39 +09:00
committed by GitHub
Unverified
parent bb4fe48c9a
commit de612c47f5
24 changed files with 456 additions and 3597 deletions
@@ -40,6 +40,7 @@ from ._executors_basic import (
BASIC_ACTION_EXECUTORS,
AppendValueExecutor,
ClearAllVariablesExecutor,
CreateConversationExecutor,
EmitEventExecutor,
ResetVariableExecutor,
SendActivityExecutor,
@@ -68,13 +69,6 @@ from ._executors_external_input import (
WaitForInputExecutor,
)
from ._factory import DeclarativeWorkflowError, WorkflowFactory
from ._handlers import ActionHandler, action_handler, get_action_handler
from ._human_input import (
ExternalLoopEvent,
QuestionRequest,
process_external_loop,
validate_input_response,
)
from ._state import WorkflowState
__all__ = [
@@ -87,7 +81,6 @@ __all__ = [
"EXTERNAL_INPUT_EXECUTORS",
"TOOL_REGISTRY_KEY",
"ActionComplete",
"ActionHandler",
"ActionTrigger",
"AgentExternalInputRequest",
"AgentExternalInputResponse",
@@ -98,6 +91,7 @@ __all__ = [
"ConfirmationExecutor",
"ContinueLoopExecutor",
"ConversationData",
"CreateConversationExecutor",
"DeclarativeActionExecutor",
"DeclarativeMessage",
"DeclarativeStateData",
@@ -109,7 +103,6 @@ __all__ = [
"EndWorkflowExecutor",
"ExternalInputRequest",
"ExternalInputResponse",
"ExternalLoopEvent",
"ExternalLoopState",
"ForeachInitExecutor",
"ForeachNextExecutor",
@@ -119,7 +112,6 @@ __all__ = [
"LoopControl",
"LoopIterationResult",
"QuestionExecutor",
"QuestionRequest",
"RequestExternalInputExecutor",
"ResetVariableExecutor",
"SendActivityExecutor",
@@ -130,8 +122,4 @@ __all__ = [
"WaitForInputExecutor",
"WorkflowFactory",
"WorkflowState",
"action_handler",
"get_action_handler",
"process_external_loop",
"validate_input_response",
]
@@ -1,652 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent invocation action handlers for declarative workflows.
This module implements handlers for:
- InvokeAzureAgent: Invoke a hosted Azure AI agent
- InvokePromptAgent: Invoke a local prompt-based agent
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any, cast
from agent_framework._types import AgentResponse, Message
from ._handlers import (
ActionContext,
AgentResponseEvent,
AgentStreamingChunkEvent,
WorkflowEvent,
action_handler,
)
from ._human_input import ExternalLoopEvent, QuestionRequest
logger = logging.getLogger("agent_framework.declarative")
def _extract_json_from_response(text: str) -> Any:
r"""Extract and parse JSON from an agent response.
Agents often return JSON wrapped in markdown code blocks or with
explanatory text. This function attempts to extract and parse the
JSON content from various formats:
1. Pure JSON: {"key": "value"}
2. Markdown code block: ```json\n{"key": "value"}\n```
3. Markdown code block (no language): ```\n{"key": "value"}\n```
4. JSON with leading/trailing text: Here's the result: {"key": "value"}
5. Multiple JSON objects: Returns the LAST valid JSON object
When multiple JSON objects are present (e.g., streaming agent responses
that emit partial then final results), this returns the last complete
JSON object, which is typically the final/complete result.
Args:
text: The raw text response from an agent
Returns:
Parsed JSON as a Python dict/list, or None if parsing fails
Raises:
json.JSONDecodeError: If no valid JSON can be extracted
"""
import re
if not text:
return None
text = text.strip()
if not text:
return None
# Try parsing as pure JSON first
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Try extracting from markdown code blocks: ```json ... ``` or ``` ... ```
# Use the last code block if there are multiple
code_block_patterns = [
r"```json\s*\n?(.*?)\n?```", # ```json ... ```
r"```\s*\n?(.*?)\n?```", # ``` ... ```
]
for pattern in code_block_patterns:
matches = list(re.finditer(pattern, text, re.DOTALL))
if matches:
# Try the last match first (most likely to be the final result)
for match in reversed(matches):
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
# Find ALL JSON objects {...} or arrays [...] in the text and return the last valid one
# This handles cases where agents stream multiple JSON objects (partial, then final)
all_json_objects: list[Any] = []
pos = 0
while pos < len(text):
# Find next { or [
json_start = -1
bracket_char = None
for i in range(pos, len(text)):
if text[i] == "{":
json_start = i
bracket_char = "{"
break
if text[i] == "[":
json_start = i
bracket_char = "["
break
if json_start < 0:
break # No more JSON objects
# Find matching closing bracket
open_bracket = bracket_char
close_bracket = "}" if open_bracket == "{" else "]"
depth = 0
in_string = False
escape_next = False
found_end = False
for i in range(json_start, len(text)):
char = text[i]
if escape_next:
escape_next = False
continue
if char == "\\":
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if char == open_bracket:
depth += 1
elif char == close_bracket:
depth -= 1
if depth == 0:
# Found the end
potential_json = text[json_start : i + 1]
try:
parsed = json.loads(potential_json)
all_json_objects.append(parsed)
except json.JSONDecodeError:
pass
pos = i + 1
found_end = True
break
if not found_end:
# Malformed JSON, move past the start character
pos = json_start + 1
# Return the last valid JSON object (most likely to be the final/complete result)
if all_json_objects:
return all_json_objects[-1]
# Unable to extract JSON
raise json.JSONDecodeError("No valid JSON found in response", text, 0)
def _build_messages_from_state(ctx: ActionContext) -> list[Message]:
"""Build the message list to send to an agent.
This collects messages from:
1. Conversation history
2. Current input (if first agent call)
3. Additional context from instructions
Args:
ctx: The action context
Returns:
List of Message objects to send to the agent
"""
messages: list[Message] = []
# Get conversation history
history = ctx.state.get("conversation.messages", [])
if history:
messages.extend(history)
return messages
@action_handler("InvokeAzureAgent")
async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]:
"""Invoke a hosted Azure AI agent.
Supports both Python-style and .NET-style YAML schemas:
Python-style schema:
kind: InvokeAzureAgent
agent: agentName
input: =expression or literal input
outputPath: Local.response
.NET-style schema:
kind: InvokeAzureAgent
agent:
name: AgentName
conversationId: =System.ConversationId
input:
arguments:
param1: value1
messages: =expression
output:
messages: Local.Response
responseObject: Local.StructuredResponse
"""
# Get agent name - support both formats
agent_config: dict[str, Any] | str | None = ctx.action.get("agent")
agent_name: str | None = None
if isinstance(agent_config, dict):
agent_name = str(agent_config.get("name")) if agent_config.get("name") else None
# Support dynamic agent name from expression
if agent_name and isinstance(agent_name, str) and agent_name.startswith("="):
evaluated = ctx.state.eval_if_expression(agent_name)
agent_name = str(evaluated) if evaluated is not None else None
elif isinstance(agent_config, str):
agent_name = agent_config
if not agent_name:
logger.warning("InvokeAzureAgent action missing 'agent' or 'agent.name' property")
return
# Get input configuration
input_config: dict[str, Any] | Any = ctx.action.get("input", {})
input_arguments: dict[str, Any] = {}
input_messages: Any = None
external_loop_when: str | None = None
if isinstance(input_config, dict):
input_config_typed = cast(dict[str, Any], input_config)
input_arguments = cast(dict[str, Any], input_config_typed.get("arguments") or {})
input_messages = input_config_typed.get("messages")
# Extract external loop configuration
external_loop = input_config_typed.get("externalLoop")
if isinstance(external_loop, dict):
external_loop_typed = cast(dict[str, Any], external_loop)
external_loop_when = str(external_loop_typed.get("when")) if external_loop_typed.get("when") else None
else:
input_messages = input_config # Treat as message directly
# Get output configuration (.NET style)
output_config: dict[str, Any] | Any = ctx.action.get("output", {})
output_messages_var: str | None = None
output_response_obj_var: str | None = None
if isinstance(output_config, dict):
output_config_typed = cast(dict[str, Any], output_config)
output_messages_var = str(output_config_typed.get("messages")) if output_config_typed.get("messages") else None
output_response_obj_var = (
str(output_config_typed.get("responseObject")) if output_config_typed.get("responseObject") else None
)
# auto_send is defined but not used currently
_auto_send: bool = bool(output_config_typed.get("autoSend", True))
# Legacy Python style output path
output_path = ctx.action.get("outputPath")
# Other properties
conversation_id = ctx.action.get("conversationId")
instructions = ctx.action.get("instructions")
tools_config: list[dict[str, Any]] = ctx.action.get("tools", [])
# Get the agent from registry
agent = ctx.agents.get(agent_name)
if agent is None:
logger.error(f"InvokeAzureAgent: agent '{agent_name}' not found in registry")
return
# Evaluate conversation ID
if conversation_id:
evaluated_conv_id = ctx.state.eval_if_expression(conversation_id)
ctx.state.set("System.ConversationId", evaluated_conv_id)
# Evaluate instructions (unused currently but may be used for prompting)
_ = ctx.state.eval_if_expression(instructions) if instructions else None
# Build messages
messages = _build_messages_from_state(ctx)
# Handle input messages from .NET style
if input_messages:
evaluated_input = ctx.state.eval_if_expression(input_messages)
if evaluated_input:
if isinstance(evaluated_input, str):
messages.append(Message(role="user", text=evaluated_input))
elif isinstance(evaluated_input, list):
for msg_item in evaluated_input: # type: ignore
if isinstance(msg_item, str):
messages.append(Message(role="user", text=msg_item))
elif isinstance(msg_item, Message):
messages.append(msg_item)
elif isinstance(msg_item, dict) and "content" in msg_item:
item_dict = cast(dict[str, Any], msg_item)
role: str = str(item_dict.get("role", "user"))
content: str = str(item_dict.get("content", ""))
if role == "user":
messages.append(Message(role="user", text=content))
elif role == "assistant":
messages.append(Message(role="assistant", text=content))
elif role == "system":
messages.append(Message(role="system", text=content))
# Evaluate and include input arguments
evaluated_args: dict[str, Any] = {}
for arg_key, arg_value in input_arguments.items():
evaluated_args[arg_key] = ctx.state.eval_if_expression(arg_value)
# Prepare tool bindings
tool_bindings: dict[str, dict[str, Any]] = {}
for tool_config in tools_config:
tool_name: str | None = str(tool_config.get("name")) if tool_config.get("name") else None
bindings: list[dict[str, Any]] = list(tool_config.get("bindings", [])) # type: ignore[arg-type]
if tool_name and bindings:
tool_bindings[tool_name] = {
str(b.get("name")): ctx.state.eval_if_expression(b.get("input")) for b in bindings if b.get("name")
}
logger.debug(f"InvokeAzureAgent: calling '{agent_name}' with {len(messages)} messages")
# External loop iteration counter
iteration = 0
max_iterations = 100 # Safety limit
# Start external loop if configured
# Build options for kwargs propagation to agent tools
run_kwargs = ctx.run_kwargs
options: dict[str, Any] | None = None
if run_kwargs:
# Merge caller-provided options to avoid duplicate keyword argument
options = dict(run_kwargs.get("options") or {})
options["additional_function_arguments"] = run_kwargs
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
while True:
# Invoke the agent
try:
# Agents use run() with stream parameter
if hasattr(agent, "run"):
# Try streaming first
try:
updates: list[Any] = []
tool_calls: list[Any] = []
async for chunk in agent.run(messages, stream=True, options=options, **run_kwargs):
updates.append(chunk)
# Yield streaming events for text chunks
if hasattr(chunk, "text") and chunk.text:
yield AgentStreamingChunkEvent(
agent_name=str(agent_name),
chunk=chunk.text,
)
# Collect tool calls
if hasattr(chunk, "tool_calls"):
tool_calls.extend(chunk.tool_calls)
# Build consolidated response from updates
response = AgentResponse.from_updates(updates)
text = response.text
response_messages = response.messages
# Update state with result
ctx.state.set_agent_result(
text=text,
messages=response_messages,
tool_calls=tool_calls if tool_calls else None,
)
# Add to conversation history
if text:
ctx.state.add_conversation_message(Message(role="assistant", text=text))
# Store in output variables (.NET style)
if output_messages_var:
output_path_mapped = _normalize_variable_path(output_messages_var)
ctx.state.set(output_path_mapped, response_messages if response_messages else text)
if output_response_obj_var:
output_path_mapped = _normalize_variable_path(output_response_obj_var)
# Try to extract and parse JSON from the response
try:
parsed = _extract_json_from_response(text) if text else None
logger.debug(
f"InvokeAzureAgent (streaming): parsed responseObject for "
f"'{output_path_mapped}': type={type(parsed).__name__}, "
f"value_preview={str(parsed)[:100] if parsed else None}"
)
ctx.state.set(output_path_mapped, parsed)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(
f"InvokeAzureAgent (streaming): failed to parse JSON for "
f"'{output_path_mapped}': {e}, text_preview={text[:100] if text else None}"
)
ctx.state.set(output_path_mapped, text)
# Store in output path (Python style)
if output_path:
ctx.state.set(output_path, text)
yield AgentResponseEvent(
agent_name=str(agent_name),
text=text,
messages=response_messages,
tool_calls=tool_calls if tool_calls else None,
)
except TypeError:
# Agent doesn't support streaming, fall back to non-streaming
response = await agent.run(messages, options=options, **run_kwargs)
text = response.text
response_messages = response.messages
response_tool_calls: list[Any] | None = getattr(response, "tool_calls", None)
# Update state with result
ctx.state.set_agent_result(
text=text,
messages=response_messages,
tool_calls=response_tool_calls,
)
# Add to conversation history
if text:
ctx.state.add_conversation_message(Message(role="assistant", text=text))
# Store in output variables (.NET style)
if output_messages_var:
output_path_mapped = _normalize_variable_path(output_messages_var)
ctx.state.set(output_path_mapped, response_messages if response_messages else text)
if output_response_obj_var:
output_path_mapped = _normalize_variable_path(output_response_obj_var)
try:
parsed = _extract_json_from_response(text) if text else None
logger.debug(
f"InvokeAzureAgent (non-streaming): parsed responseObject for "
f"'{output_path_mapped}': type={type(parsed).__name__}, "
f"value_preview={str(parsed)[:100] if parsed else None}"
)
ctx.state.set(output_path_mapped, parsed)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(
f"InvokeAzureAgent (non-streaming): failed to parse JSON for "
f"'{output_path_mapped}': {e}, text_preview={text[:100] if text else None}"
)
ctx.state.set(output_path_mapped, text)
# Store in output path (Python style)
if output_path:
ctx.state.set(output_path, text)
yield AgentResponseEvent(
agent_name=str(agent_name),
text=text,
messages=response_messages,
tool_calls=response_tool_calls,
)
else:
logger.error(f"InvokeAzureAgent: agent '{agent_name}' has no run method")
break
except Exception as e:
logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}': {e}")
raise
# Check external loop condition
if external_loop_when:
# Evaluate the loop condition
should_continue = ctx.state.eval(external_loop_when)
should_continue = bool(should_continue) if should_continue is not None else False
logger.debug(
f"InvokeAzureAgent: external loop condition '{str(external_loop_when)[:50]}' = "
f"{should_continue} (iteration {iteration})"
)
if should_continue and iteration < max_iterations:
# Emit event to signal waiting for external input
action_id: str = str(ctx.action.get("id", f"agent_{agent_name}"))
yield ExternalLoopEvent(
action_id=action_id,
iteration=iteration,
condition_expression=str(external_loop_when),
)
# The workflow executor should:
# 1. Pause execution
# 2. Wait for external input
# 3. Update state with input
# 4. Resume this generator
# For now, we request input via QuestionRequest
yield QuestionRequest(
request_id=f"{action_id}_input_{iteration}",
prompt="Waiting for user input...",
variable="Local.userInput",
)
iteration += 1
# Clear messages for next iteration (start fresh with conversation)
messages = _build_messages_from_state(ctx)
continue
elif iteration >= max_iterations:
logger.warning(f"InvokeAzureAgent: external loop exceeded max iterations ({max_iterations})")
# No external loop or condition is false - exit
break
def _normalize_variable_path(variable: str) -> str:
"""Normalize variable names to ensure they have a scope prefix.
Args:
variable: Variable name like 'Local.X' or 'System.ConversationId'
Returns:
The variable path with a scope prefix (defaults to Local if none provided)
"""
if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
# Already has a proper namespace
return variable
if "." in variable:
# Has some namespace, use as-is
return variable
# Default to Local scope
return "Local." + variable
@action_handler("InvokePromptAgent")
async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]:
"""Invoke a local prompt-based agent (similar to InvokeAzureAgent but for local agents).
Action schema:
kind: InvokePromptAgent
agent: agentName # name of the agent in the agents registry
input: =expression or literal input
instructions: =expression or literal prompt/instructions
outputPath: Local.response # optional path to store result
"""
# Implementation is similar to InvokeAzureAgent
# The difference is primarily in how the agent is configured
agent_name_raw = ctx.action.get("agent")
if not isinstance(agent_name_raw, str):
logger.warning("InvokePromptAgent action missing 'agent' property")
return
agent_name: str = agent_name_raw
input_expr = ctx.action.get("input")
instructions = ctx.action.get("instructions")
output_path = ctx.action.get("outputPath")
# Get the agent from registry
agent = ctx.agents.get(agent_name)
if agent is None:
logger.error(f"InvokePromptAgent: agent '{agent_name}' not found in registry")
return
# Evaluate input
input_value = ctx.state.eval_if_expression(input_expr) if input_expr else None
# Evaluate instructions (unused currently but may be used for prompting)
_ = ctx.state.eval_if_expression(instructions) if instructions else None
# Build messages
messages = _build_messages_from_state(ctx)
# Add input as user message if provided
if input_value:
if isinstance(input_value, str):
messages.append(Message(role="user", text=input_value))
elif isinstance(input_value, Message):
messages.append(input_value)
logger.debug(f"InvokePromptAgent: calling '{agent_name}' with {len(messages)} messages")
# Build options for kwargs propagation to agent tools
prompt_run_kwargs = ctx.run_kwargs
prompt_options: dict[str, Any] | None = None
if prompt_run_kwargs:
# Merge caller-provided options to avoid duplicate keyword argument
prompt_options = dict(prompt_run_kwargs.get("options") or {})
prompt_options["additional_function_arguments"] = prompt_run_kwargs
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
prompt_run_kwargs = {k: v for k, v in prompt_run_kwargs.items() if k != "options"}
# Invoke the agent
try:
if hasattr(agent, "run"):
# Try streaming first
try:
updates: list[Any] = []
async for chunk in agent.run(messages, stream=True, options=prompt_options, **prompt_run_kwargs):
updates.append(chunk)
if hasattr(chunk, "text") and chunk.text:
yield AgentStreamingChunkEvent(
agent_name=agent_name,
chunk=chunk.text,
)
# Build consolidated response from updates
response = AgentResponse.from_updates(updates)
text = response.text
response_messages = response.messages
ctx.state.set_agent_result(text=text, messages=response_messages)
if text:
ctx.state.add_conversation_message(Message(role="assistant", text=text))
if output_path:
ctx.state.set(output_path, text)
yield AgentResponseEvent(
agent_name=agent_name,
text=text,
messages=response_messages,
)
except TypeError:
# Agent doesn't support streaming, fall back to non-streaming
response = await agent.run(messages, options=prompt_options, **prompt_run_kwargs)
text = response.text
response_messages = response.messages
ctx.state.set_agent_result(text=text, messages=response_messages)
if text:
ctx.state.add_conversation_message(Message(role="assistant", text=text))
if output_path:
ctx.state.set(output_path, text)
yield AgentResponseEvent(
agent_name=agent_name,
text=text,
messages=response_messages,
)
else:
logger.error(f"InvokePromptAgent: agent '{agent_name}' has no run method")
except Exception as e:
logger.error(f"InvokePromptAgent: error invoking agent '{agent_name}': {e}")
raise
@@ -1,572 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Basic action handlers for variable manipulation and output.
This module implements handlers for:
- SetValue: Set a variable in the workflow state
- AppendValue: Append a value to a list variable
- SendActivity: Send text or attachments to the user
- EmitEvent: Emit a custom workflow event
Note: All handlers are defined as async generators (AsyncGenerator[WorkflowEvent, None])
for consistency with the ActionHandler protocol, even when they don't perform async
operations. This uniform interface allows the workflow executor to consume all handlers
the same way, and some handlers (like InvokeAzureAgent) genuinely require async for
network calls. The `return; yield` pattern makes a function an async generator without
actually yielding any events.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any, cast
from ._handlers import (
ActionContext,
AttachmentOutputEvent,
CustomEvent,
TextOutputEvent,
WorkflowEvent,
action_handler,
)
if TYPE_CHECKING:
from ._state import WorkflowState
logger = logging.getLogger("agent_framework.declarative")
@action_handler("SetValue")
async def handle_set_value(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Set a value in the workflow state.
Action schema:
kind: SetValue
path: Local.variableName # or Workflow.Outputs.result
value: =expression or literal value
"""
path = ctx.action.get("path")
value = ctx.action.get("value")
if not path:
logger.warning("SetValue action missing 'path' property")
return
# Evaluate the value if it's an expression
evaluated_value = ctx.state.eval_if_expression(value)
logger.debug(f"SetValue: {path} = {evaluated_value}")
ctx.state.set(path, evaluated_value)
return
yield # Make it a generator
@action_handler("SetVariable")
async def handle_set_variable(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Set a variable in the workflow state (.NET workflow format).
This is an alias for SetValue with 'variable' instead of 'path'.
Action schema:
kind: SetVariable
variable: Local.variableName
value: =expression or literal value
"""
variable = ctx.action.get("variable")
value = ctx.action.get("value")
if not variable:
logger.warning("SetVariable action missing 'variable' property")
return
# Evaluate the value if it's an expression
evaluated_value = ctx.state.eval_if_expression(value)
# Use .NET-style variable names directly (Local.X, System.X, Workflow.X)
path = _normalize_variable_path(variable)
logger.debug(f"SetVariable: {variable} ({path}) = {evaluated_value}")
ctx.state.set(path, evaluated_value)
return
yield # Make it a generator
def _normalize_variable_path(variable: str) -> str:
"""Normalize variable names to ensure they have a scope prefix.
Args:
variable: Variable name like 'Local.X' or 'System.ConversationId'
Returns:
The variable path with a scope prefix (defaults to Local if none provided)
"""
if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
# Already has a proper namespace
return variable
if "." in variable:
# Has some namespace, use as-is
return variable
# Default to Local scope
return "Local." + variable
@action_handler("AppendValue")
async def handle_append_value(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Append a value to a list in the workflow state.
Action schema:
kind: AppendValue
path: Local.results
value: =expression or literal value
"""
path = ctx.action.get("path")
value = ctx.action.get("value")
if not path:
logger.warning("AppendValue action missing 'path' property")
return
# Evaluate the value if it's an expression
evaluated_value = ctx.state.eval_if_expression(value)
logger.debug(f"AppendValue: {path} += {evaluated_value}")
ctx.state.append(path, evaluated_value)
return
yield # Make it a generator
@action_handler("SendActivity")
async def handle_send_activity(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Send text or attachments to the user.
Action schema (object form):
kind: SendActivity
activity:
text: =expression or literal text
attachments:
- content: ...
contentType: text/plain
Action schema (simple form):
kind: SendActivity
activity: =expression or literal text
"""
activity = ctx.action.get("activity", {})
# Handle simple string form
if isinstance(activity, str):
evaluated_text = ctx.state.eval_if_expression(activity)
if evaluated_text:
logger.debug(
"SendActivity: text = %s", evaluated_text[:100] if len(str(evaluated_text)) > 100 else evaluated_text
)
yield TextOutputEvent(text=str(evaluated_text))
return
# Handle object form - text output
text = activity.get("text")
if text:
evaluated_text = ctx.state.eval_if_expression(text)
if evaluated_text:
logger.debug(
"SendActivity: text = %s", evaluated_text[:100] if len(str(evaluated_text)) > 100 else evaluated_text
)
yield TextOutputEvent(text=str(evaluated_text))
# Handle attachments
attachments = activity.get("attachments", [])
for attachment in attachments:
content = attachment.get("content")
content_type = attachment.get("contentType", "application/octet-stream")
if content:
evaluated_content = ctx.state.eval_if_expression(content)
logger.debug(f"SendActivity: attachment type={content_type}")
yield AttachmentOutputEvent(content=evaluated_content, content_type=content_type)
@action_handler("EmitEvent")
async def handle_emit_event(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Emit a custom workflow event.
Action schema:
kind: EmitEvent
event:
name: eventName
data: =expression or literal data
"""
event_def = ctx.action.get("event", {})
name = event_def.get("name")
data = event_def.get("data")
if not name:
logger.warning("EmitEvent action missing 'event.name' property")
return
# Evaluate data if it's an expression
evaluated_data = ctx.state.eval_if_expression(data)
logger.debug(f"EmitEvent: {name} = {evaluated_data}")
yield CustomEvent(name=name, data=evaluated_data)
def _evaluate_dict_values(d: dict[str, Any], state: WorkflowState) -> dict[str, Any]:
"""Recursively evaluate PowerFx expressions in a dictionary.
Args:
d: Dictionary that may contain expression values
state: The workflow state for expression evaluation
Returns:
Dictionary with all expressions evaluated
"""
result: dict[str, Any] = {}
for key, value in d.items():
if isinstance(value, str):
result[key] = state.eval_if_expression(value)
elif isinstance(value, dict):
result[key] = _evaluate_dict_values(cast(dict[str, Any], value), state)
elif isinstance(value, list):
evaluated_list: list[Any] = []
for list_item in value:
if isinstance(list_item, dict):
evaluated_list.append(_evaluate_dict_values(cast(dict[str, Any], list_item), state))
elif isinstance(list_item, str):
evaluated_list.append(state.eval_if_expression(list_item))
else:
evaluated_list.append(list_item)
result[key] = evaluated_list
else:
result[key] = value
return result
@action_handler("SetTextVariable")
async def handle_set_text_variable(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Set a text variable with string interpolation support.
This is similar to SetVariable but supports multi-line text with
{Local.Variable} style interpolation.
Action schema:
kind: SetTextVariable
variable: Local.myText
value: |-
Multi-line text with {Local.Variable} interpolation
and more content here.
"""
variable = ctx.action.get("variable")
value = ctx.action.get("value")
if not variable:
logger.warning("SetTextVariable action missing 'variable' property")
return
# Evaluate the value - handle string interpolation
if isinstance(value, str):
evaluated_value = _interpolate_string(value, ctx.state)
else:
evaluated_value = ctx.state.eval_if_expression(value)
path = _normalize_variable_path(variable)
logger.debug(f"SetTextVariable: {variable} ({path}) = {str(evaluated_value)[:100]}")
ctx.state.set(path, evaluated_value)
return
yield # Make it a generator
@action_handler("SetMultipleVariables")
async def handle_set_multiple_variables(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Set multiple variables at once.
Action schema:
kind: SetMultipleVariables
variables:
- variable: Local.var1
value: value1
- variable: Local.var2
value: =expression
"""
variables = ctx.action.get("variables", [])
for var_def in variables:
variable = var_def.get("variable")
value = var_def.get("value")
if not variable:
logger.warning("SetMultipleVariables: variable entry missing 'variable' property")
continue
evaluated_value = ctx.state.eval_if_expression(value)
path = _normalize_variable_path(variable)
logger.debug(f"SetMultipleVariables: {variable} ({path}) = {evaluated_value}")
ctx.state.set(path, evaluated_value)
return
yield # Make it a generator
@action_handler("ResetVariable")
async def handle_reset_variable(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Reset a variable to its default/blank state.
Action schema:
kind: ResetVariable
variable: Local.variableName
"""
variable = ctx.action.get("variable")
if not variable:
logger.warning("ResetVariable action missing 'variable' property")
return
path = _normalize_variable_path(variable)
logger.debug(f"ResetVariable: {variable} ({path}) = None")
ctx.state.set(path, None)
return
yield # Make it a generator
@action_handler("ClearAllVariables")
async def handle_clear_all_variables(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Clear all turn-scoped variables.
Action schema:
kind: ClearAllVariables
"""
logger.debug("ClearAllVariables: clearing turn scope")
ctx.state.reset_local()
return
yield # Make it a generator
@action_handler("CreateConversation")
async def handle_create_conversation(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Create a new conversation context.
Action schema (.NET style):
kind: CreateConversation
conversationId: Local.myConversationId # Variable to store the generated ID
The conversationId parameter is the OUTPUT variable where the generated
conversation ID will be stored. This matches .NET behavior where:
- A unique conversation ID is always auto-generated
- The conversationId parameter specifies where to store it
"""
import uuid
conversation_id_var = ctx.action.get("conversationId")
# Always generate a unique ID (.NET behavior)
generated_id = str(uuid.uuid4())
# Store conversation in state
conversations: dict[str, Any] = ctx.state.get("System.conversations") or {}
conversations[generated_id] = {
"id": generated_id,
"messages": [],
"created_at": None, # Could add timestamp
}
ctx.state.set("System.conversations", conversations)
logger.debug(f"CreateConversation: created {generated_id}")
# Store the generated ID in the specified variable (.NET style output binding)
if conversation_id_var:
output_path = _normalize_variable_path(conversation_id_var)
ctx.state.set(output_path, generated_id)
logger.debug(f"CreateConversation: bound to {output_path} = {generated_id}")
# Also handle legacy output binding for backwards compatibility
output = ctx.action.get("output", {})
output_var = output.get("conversationId")
if output_var:
output_path = _normalize_variable_path(output_var)
ctx.state.set(output_path, generated_id)
logger.debug(f"CreateConversation: legacy output bound to {output_path}")
return
yield # Make it a generator
@action_handler("AddConversationMessage")
async def handle_add_conversation_message(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Add a message to a conversation.
Action schema:
kind: AddConversationMessage
conversationId: =expression or variable reference
message:
role: user | assistant | system
content: =expression or literal text
"""
conversation_id = ctx.action.get("conversationId")
message_def = ctx.action.get("message", {})
if not conversation_id:
logger.warning("AddConversationMessage missing 'conversationId' property")
return
# Evaluate conversation ID
evaluated_id = ctx.state.eval_if_expression(conversation_id)
# Evaluate message content
role = message_def.get("role", "user")
content = message_def.get("content", "")
evaluated_content = ctx.state.eval_if_expression(content)
if isinstance(evaluated_content, str):
evaluated_content = _interpolate_string(evaluated_content, ctx.state)
# Get or create conversation
conversations: dict[str, Any] = ctx.state.get("System.conversations") or {}
if evaluated_id not in conversations:
conversations[evaluated_id] = {"id": evaluated_id, "messages": []}
# Add message
message: dict[str, Any] = {"role": role, "content": evaluated_content}
conv_entry: dict[str, Any] = dict(conversations[evaluated_id])
messages_list: list[Any] = list(conv_entry.get("messages", []))
messages_list.append(message)
conv_entry["messages"] = messages_list
conversations[evaluated_id] = conv_entry
ctx.state.set("System.conversations", conversations)
# Also add to global conversation state
ctx.state.add_conversation_message(message)
logger.debug(f"AddConversationMessage: added {role} message to {evaluated_id}")
return
yield # Make it a generator
@action_handler("CopyConversationMessages")
async def handle_copy_conversation_messages(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Copy messages from one conversation to another.
Action schema:
kind: CopyConversationMessages
sourceConversationId: =expression
targetConversationId: =expression
count: 10 # optional, number of messages to copy
"""
source_id = ctx.action.get("sourceConversationId")
target_id = ctx.action.get("targetConversationId")
count = ctx.action.get("count")
if not source_id or not target_id:
logger.warning("CopyConversationMessages missing source or target conversation ID")
return
# Evaluate IDs
evaluated_source = ctx.state.eval_if_expression(source_id)
evaluated_target = ctx.state.eval_if_expression(target_id)
# Get conversations
conversations: dict[str, Any] = ctx.state.get("System.conversations") or {}
source_conv: dict[str, Any] = conversations.get(evaluated_source, {})
source_messages: list[Any] = source_conv.get("messages", [])
# Limit messages if count specified
if count is not None:
source_messages = source_messages[-count:]
# Get or create target conversation
if evaluated_target not in conversations:
conversations[evaluated_target] = {"id": evaluated_target, "messages": []}
# Copy messages
target_entry: dict[str, Any] = dict(conversations[evaluated_target])
target_messages: list[Any] = list(target_entry.get("messages", []))
target_messages.extend(source_messages)
target_entry["messages"] = target_messages
conversations[evaluated_target] = target_entry
ctx.state.set("System.conversations", conversations)
logger.debug(
"CopyConversationMessages: copied %d messages from %s to %s",
len(source_messages),
evaluated_source,
evaluated_target,
)
return
yield # Make it a generator
@action_handler("RetrieveConversationMessages")
async def handle_retrieve_conversation_messages(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Retrieve messages from a conversation and store in a variable.
Action schema:
kind: RetrieveConversationMessages
conversationId: =expression
output:
messages: Local.myMessages
count: 10 # optional
"""
conversation_id = ctx.action.get("conversationId")
output = ctx.action.get("output", {})
count = ctx.action.get("count")
if not conversation_id:
logger.warning("RetrieveConversationMessages missing 'conversationId' property")
return
# Evaluate conversation ID
evaluated_id = ctx.state.eval_if_expression(conversation_id)
# Get messages
conversations: dict[str, Any] = ctx.state.get("System.conversations") or {}
conv: dict[str, Any] = conversations.get(evaluated_id, {})
messages: list[Any] = conv.get("messages", [])
# Limit messages if count specified
if count is not None:
messages = messages[-count:]
# Handle output binding
output_var = output.get("messages")
if output_var:
output_path = _normalize_variable_path(output_var)
ctx.state.set(output_path, messages)
logger.debug(f"RetrieveConversationMessages: bound {len(messages)} messages to {output_path}")
return
yield # Make it a generator
def _interpolate_string(text: str, state: WorkflowState) -> str:
"""Interpolate {Variable.Path} references in a string.
Args:
text: Text that may contain {Variable.Path} references
state: The workflow state for variable lookup
Returns:
Text with variables interpolated
"""
import re
def replace_var(match: re.Match[str]) -> str:
var_path: str = match.group(1)
# Map .NET style to Python style
path = _normalize_variable_path(var_path)
value = state.get(path)
return str(value) if value is not None else ""
# Match {Variable.Path} patterns
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
return re.sub(pattern, replace_var, text)
@@ -1,396 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Control flow action handlers for declarative workflows.
This module implements handlers for:
- Foreach: Iterate over a collection and execute nested actions
- If: Conditional branching
- Switch: Multi-way branching based on value matching
- RepeatUntil: Loop until a condition is met
- BreakLoop: Exit the current loop
- ContinueLoop: Skip to the next iteration
"""
import logging
from collections.abc import AsyncGenerator
from ._handlers import (
ActionContext,
LoopControlSignal,
WorkflowEvent,
action_handler,
)
logger = logging.getLogger("agent_framework.declarative")
@action_handler("Foreach")
async def handle_foreach(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]:
"""Iterate over a collection and execute nested actions for each item.
Action schema:
kind: Foreach
source: =expression returning a collection
itemName: itemVariable # optional, defaults to 'item'
indexName: indexVariable # optional, defaults to 'index'
actions:
- kind: ...
"""
source_expr = ctx.action.get("source")
item_name = ctx.action.get("itemName", "item")
index_name = ctx.action.get("indexName", "index")
actions = ctx.action.get("actions", [])
if not source_expr:
logger.warning("Foreach action missing 'source' property")
return
# Evaluate the source collection
collection = ctx.state.eval_if_expression(source_expr)
if collection is None:
logger.debug("Foreach: source evaluated to None, skipping")
return
if not hasattr(collection, "__iter__"):
logger.warning(f"Foreach: source is not iterable: {type(collection).__name__}")
return
collection_len = len(list(collection)) if hasattr(collection, "__len__") else "?"
logger.debug(f"Foreach: iterating over {collection_len} items")
# Iterate over the collection
for index, item in enumerate(collection):
# Set loop variables in the Local scope
ctx.state.set(f"Local.{item_name}", item)
ctx.state.set(f"Local.{index_name}", index)
# Execute nested actions
try:
async for event in ctx.execute_actions(actions, ctx.state):
# Check for loop control signals
if isinstance(event, LoopControlSignal):
if event.signal_type == "break":
logger.debug(f"Foreach: break signal received at index {index}")
return
elif event.signal_type == "continue":
logger.debug(f"Foreach: continue signal received at index {index}")
break # Break inner loop to continue outer
else:
yield event
except StopIteration:
# Continue signal was raised
continue
@action_handler("If")
async def handle_if(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]:
"""Conditional branching based on a condition expression.
Action schema:
kind: If
condition: =boolean expression
then:
- kind: ... # actions if condition is true
else:
- kind: ... # actions if condition is false (optional)
"""
condition_expr = ctx.action.get("condition")
then_actions = ctx.action.get("then", [])
else_actions = ctx.action.get("else", [])
if condition_expr is None:
logger.warning("If action missing 'condition' property")
return
# Evaluate the condition
condition_result = ctx.state.eval_if_expression(condition_expr)
# Coerce to boolean
is_truthy = bool(condition_result)
logger.debug(
"If: condition '%s' evaluated to %s",
condition_expr[:50] if len(str(condition_expr)) > 50 else condition_expr,
is_truthy,
)
# Execute the appropriate branch
actions_to_execute = then_actions if is_truthy else else_actions
async for event in ctx.execute_actions(actions_to_execute, ctx.state):
yield event
@action_handler("Switch")
async def handle_switch(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]:
"""Multi-way branching based on value matching.
Action schema:
kind: Switch
value: =expression to match
cases:
- match: value1
actions:
- kind: ...
- match: value2
actions:
- kind: ...
default:
- kind: ... # optional default actions
"""
value_expr = ctx.action.get("value")
cases = ctx.action.get("cases", [])
default_actions = ctx.action.get("default", [])
if not value_expr:
logger.warning("Switch action missing 'value' property")
return
# Evaluate the switch value
switch_value = ctx.state.eval_if_expression(value_expr)
logger.debug(f"Switch: value = {switch_value}")
# Find matching case
matched_actions = None
for case in cases:
match_value = ctx.state.eval_if_expression(case.get("match"))
if switch_value == match_value:
matched_actions = case.get("actions", [])
logger.debug(f"Switch: matched case '{match_value}'")
break
# Use default if no match found
if matched_actions is None:
matched_actions = default_actions
logger.debug("Switch: using default case")
# Execute matched actions
async for event in ctx.execute_actions(matched_actions, ctx.state):
yield event
@action_handler("RepeatUntil")
async def handle_repeat_until(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]:
"""Loop until a condition becomes true.
Action schema:
kind: RepeatUntil
condition: =boolean expression (loop exits when true)
maxIterations: 100 # optional safety limit
actions:
- kind: ...
"""
condition_expr = ctx.action.get("condition")
max_iterations = ctx.action.get("maxIterations", 100)
actions = ctx.action.get("actions", [])
if condition_expr is None:
logger.warning("RepeatUntil action missing 'condition' property")
return
iteration = 0
while iteration < max_iterations:
iteration += 1
ctx.state.set("Local.iteration", iteration)
logger.debug(f"RepeatUntil: iteration {iteration}")
# Execute loop body
should_break = False
async for event in ctx.execute_actions(actions, ctx.state):
if isinstance(event, LoopControlSignal):
if event.signal_type == "break":
logger.debug(f"RepeatUntil: break signal received at iteration {iteration}")
should_break = True
break
elif event.signal_type == "continue":
logger.debug(f"RepeatUntil: continue signal received at iteration {iteration}")
break
else:
yield event
if should_break:
break
# Check exit condition
condition_result = ctx.state.eval_if_expression(condition_expr)
if bool(condition_result):
logger.debug(f"RepeatUntil: condition met after {iteration} iterations")
break
if iteration >= max_iterations:
logger.warning(f"RepeatUntil: reached max iterations ({max_iterations})")
@action_handler("BreakLoop")
async def handle_break_loop(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029
"""Signal to break out of the current loop.
Action schema:
kind: BreakLoop
"""
logger.debug("BreakLoop: signaling break")
yield LoopControlSignal(signal_type="break")
@action_handler("ContinueLoop")
async def handle_continue_loop(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029
"""Signal to continue to the next iteration of the current loop.
Action schema:
kind: ContinueLoop
"""
logger.debug("ContinueLoop: signaling continue")
yield LoopControlSignal(signal_type="continue")
@action_handler("ConditionGroup")
async def handle_condition_group(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]:
"""Multi-condition branching (like else-if chains).
Evaluates conditions in order and executes the first matching condition's actions.
If no conditions match and elseActions is provided, executes those.
Action schema:
kind: ConditionGroup
conditions:
- condition: =boolean expression
actions:
- kind: ...
- condition: =another expression
actions:
- kind: ...
elseActions:
- kind: ... # optional, executed if no conditions match
"""
conditions = ctx.action.get("conditions", [])
else_actions = ctx.action.get("elseActions", [])
matched = False
for condition_def in conditions:
condition_expr = condition_def.get("condition")
actions = condition_def.get("actions", [])
if condition_expr is None:
logger.warning("ConditionGroup condition missing 'condition' property")
continue
# Evaluate the condition
condition_result = ctx.state.eval_if_expression(condition_expr)
is_truthy = bool(condition_result)
logger.debug(
"ConditionGroup: condition '%s' evaluated to %s",
str(condition_expr)[:50] if len(str(condition_expr)) > 50 else condition_expr,
is_truthy,
)
if is_truthy:
matched = True
# Execute this condition's actions
async for event in ctx.execute_actions(actions, ctx.state):
yield event
# Only execute the first matching condition
break
# Execute elseActions if no condition matched
if not matched and else_actions:
logger.debug("ConditionGroup: no conditions matched, executing elseActions")
async for event in ctx.execute_actions(else_actions, ctx.state):
yield event
@action_handler("GotoAction")
async def handle_goto_action(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029
"""Jump to another action by ID (triggers re-execution from that action).
Note: GotoAction in the .NET implementation creates a loop by restarting
execution from a specific action. In Python, we emit a GotoSignal that
the top-level executor should handle.
Action schema:
kind: GotoAction
actionId: target_action_id
"""
action_id = ctx.action.get("actionId")
if not action_id:
logger.warning("GotoAction missing 'actionId' property")
return
logger.debug(f"GotoAction: jumping to action '{action_id}'")
# Emit a goto signal that the executor should handle
yield GotoSignal(target_action_id=action_id)
class GotoSignal(WorkflowEvent):
"""Signal to jump to a specific action by ID.
This signal is used by GotoAction to implement control flow jumps.
The top-level executor should handle this signal appropriately.
"""
def __init__(self, target_action_id: str) -> None:
self.target_action_id = target_action_id
class EndWorkflowSignal(WorkflowEvent):
"""Signal to end the workflow execution.
This signal causes the workflow to terminate gracefully.
"""
def __init__(self, reason: str | None = None) -> None:
self.reason = reason
class EndConversationSignal(WorkflowEvent):
"""Signal to end the current conversation.
This signal causes the conversation to terminate while the workflow may continue.
"""
def __init__(self, conversation_id: str | None = None, reason: str | None = None) -> None:
self.conversation_id = conversation_id
self.reason = reason
@action_handler("EndWorkflow")
async def handle_end_workflow(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029
"""End the workflow execution.
Action schema:
kind: EndWorkflow
reason: Optional reason for ending (for logging)
"""
reason = ctx.action.get("reason")
logger.debug(f"EndWorkflow: ending workflow{f' (reason: {reason})' if reason else ''}")
yield EndWorkflowSignal(reason=reason)
@action_handler("EndConversation")
async def handle_end_conversation(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029
"""End the current conversation.
Action schema:
kind: EndConversation
conversationId: Optional specific conversation to end
reason: Optional reason for ending
"""
conversation_id = ctx.action.get("conversationId")
reason = ctx.action.get("reason")
# Evaluate conversation ID if provided
if conversation_id:
evaluated_id = ctx.state.eval_if_expression(conversation_id)
else:
evaluated_id = ctx.state.get("System.ConversationId")
logger.debug(f"EndConversation: ending conversation {evaluated_id}{f' (reason: {reason})' if reason else ''}")
yield EndConversationSignal(conversation_id=evaluated_id, reason=reason)
@@ -1,133 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Error handling action handlers for declarative workflows.
This module implements handlers for:
- ThrowException: Raise an error that can be caught by TryCatch
- TryCatch: Try-catch-finally error handling
"""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from agent_framework.exceptions import WorkflowException
from ._handlers import (
ActionContext,
WorkflowEvent,
action_handler,
)
logger = logging.getLogger("agent_framework.declarative")
class WorkflowActionError(WorkflowException):
"""Exception raised by ThrowException action."""
def __init__(self, message: str, code: str | None = None):
super().__init__(message)
self.code = code
@dataclass
class ErrorEvent(WorkflowEvent):
"""Event emitted when an error occurs."""
message: str
"""The error message."""
code: str | None = None
"""Optional error code."""
source_action: str | None = None
"""The action that caused the error."""
@action_handler("ThrowException")
async def handle_throw_exception(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Raise an exception that can be caught by TryCatch.
Action schema:
kind: ThrowException
message: =expression or literal error message
code: ERROR_CODE # optional error code
"""
message_expr = ctx.action.get("message", "An error occurred")
code = ctx.action.get("code")
# Evaluate the message if it's an expression
message = ctx.state.eval_if_expression(message_expr)
logger.debug(f"ThrowException: {message} (code={code})")
raise WorkflowActionError(str(message), code)
# This yield is never reached but makes it a generator
yield ErrorEvent(message=str(message), code=code) # type: ignore[unreachable]
@action_handler("TryCatch")
async def handle_try_catch(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]:
"""Try-catch-finally error handling.
Action schema:
kind: TryCatch
try:
- kind: ... # actions to try
catch:
- kind: ... # actions to execute on error (optional)
finally:
- kind: ... # actions to always execute (optional)
In the catch block, the following variables are available:
Local.error.message: The error message
Local.error.code: The error code (if provided)
Local.error.type: The error type name
"""
try_actions = ctx.action.get("try", [])
catch_actions = ctx.action.get("catch", [])
finally_actions = ctx.action.get("finally", [])
error_occurred = False
error_info = None
# Execute try block
try:
async for event in ctx.execute_actions(try_actions, ctx.state):
yield event
except WorkflowActionError as e:
error_occurred = True
error_info = {
"message": str(e),
"code": e.code,
"type": "WorkflowActionError",
}
logger.debug(f"TryCatch: caught WorkflowActionError: {e}")
except Exception as e:
error_occurred = True
error_info = {
"message": str(e),
"code": None,
"type": type(e).__name__,
}
logger.debug(f"TryCatch: caught {type(e).__name__}: {e}")
# Execute catch block if error occurred
if error_occurred and catch_actions:
# Set error info in Local scope
ctx.state.set("Local.error", error_info)
try:
async for event in ctx.execute_actions(catch_actions, ctx.state):
yield event
finally:
# Clean up error info (but don't interfere with finally block)
pass
# Execute finally block
if finally_actions:
async for event in ctx.execute_actions(finally_actions, ctx.state):
yield event
@@ -27,6 +27,7 @@ from __future__ import annotations
import logging
import sys
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal as _Decimal
@@ -162,15 +163,19 @@ class DeclarativeWorkflowState:
Args:
inputs: Initial workflow inputs (become Workflow.Inputs.*)
"""
conversation_id = str(uuid.uuid4())
state_data: DeclarativeStateData = {
"Inputs": dict(inputs) if inputs else {},
"Outputs": {},
"Local": {},
"System": {
"ConversationId": "default",
"ConversationId": conversation_id,
"LastMessage": {"Text": "", "Id": ""},
"LastMessageText": "",
"LastMessageId": "",
"conversations": {
conversation_id: {"id": conversation_id, "messages": []},
},
},
"Agent": {},
"Conversation": {"messages": [], "history": []},
@@ -854,12 +859,18 @@ class DeclarativeActionExecutor(Executor):
# Structured inputs - use directly
state.initialize(trigger) # type: ignore
elif isinstance(trigger, str):
# String input - wrap in dict
# String input - wrap in dict and populate System.LastMessage.Text
# so YAML expressions like =System.LastMessage.Text see the user input
state.initialize({"input": trigger})
state.set("System.LastMessage", {"Text": trigger, "Id": ""})
state.set("System.LastMessageText", trigger)
elif not isinstance(
trigger, (ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl)
):
# Any other type - convert to string like .NET's DefaultTransform
state.initialize({"input": str(trigger)})
input_str = str(trigger)
state.initialize({"input": input_str})
state.set("System.LastMessage", {"Text": input_str, "Id": ""})
state.set("System.LastMessageText", input_str)
return state
@@ -49,7 +49,17 @@ ALL_ACTION_EXECUTORS = {
# Action kinds that terminate control flow (no fall-through to successor)
# These actions transfer control elsewhere and should not have sequential edges to the next action
TERMINATOR_ACTIONS = frozenset({"Goto", "GotoAction", "BreakLoop", "ContinueLoop", "EndWorkflow", "EndDialog"})
TERMINATOR_ACTIONS = frozenset({
"Goto",
"GotoAction",
"BreakLoop",
"ContinueLoop",
"EndWorkflow",
"EndDialog",
"EndConversation",
"CancelDialog",
"CancelAllDialogs",
})
# Required fields for specific action kinds (schema validation)
# Each action needs at least one of the listed fields (checked with alternates)
@@ -110,6 +120,7 @@ class DeclarativeWorkflowBuilder:
agents: dict[str, Any] | None = None,
checkpoint_storage: Any | None = None,
validate: bool = True,
max_iterations: int | None = None,
):
"""Initialize the builder.
@@ -119,6 +130,8 @@ class DeclarativeWorkflowBuilder:
agents: Registry of agent instances by name (for InvokeAzureAgent actions)
checkpoint_storage: Optional checkpoint storage for pause/resume support
validate: Whether to validate the workflow definition before building (default: True)
max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
field, then to the core default (100).
"""
self._yaml_def = yaml_definition
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
@@ -129,6 +142,11 @@ class DeclarativeWorkflowBuilder:
self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
self._validate = validate
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
raise ValueError(f"Invalid max_iterations/maxTurns value: {resolved!r}. Expected a positive integer.")
self._max_iterations: int | None = resolved
def build(self) -> Workflow:
"""Build the workflow graph.
@@ -153,11 +171,14 @@ class DeclarativeWorkflowBuilder:
# _create_executors_for_actions runs (which itself needs the builder to add edges).
entry_node = JoinExecutor({"kind": "Entry"}, id="_workflow_entry")
self._executors[entry_node.id] = entry_node
builder = WorkflowBuilder(
start_executor=entry_node,
name=self._workflow_id,
checkpoint_storage=self._checkpoint_storage,
)
builder_kwargs: dict[str, Any] = {
"start_executor": entry_node,
"name": self._workflow_id,
"checkpoint_storage": self._checkpoint_storage,
}
if self._max_iterations is not None:
builder_kwargs["max_iterations"] = self._max_iterations
builder = WorkflowBuilder(**builder_kwargs)
# Create all executors and wire sequential edges
first_executor = self._create_executors_for_actions(actions, builder)
@@ -944,6 +965,11 @@ class DeclarativeWorkflowBuilder:
last_executor = chain[-1]
# Skip terminators — they handle their own control flow
action_def = getattr(last_executor, "_action_def", {})
if isinstance(action_def, dict) and action_def.get("kind", "") in TERMINATOR_ACTIONS:
return None
# Check if last executor is a structure with branch_exits
# In that case, we return the structure so its exits can be collected
if hasattr(last_executor, "branch_exits"):
@@ -430,17 +430,27 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
agent_config = self._action_def.get("agent")
if isinstance(agent_config, str):
if agent_config.startswith("="):
evaluated = state.eval_if_expression(agent_config)
return str(evaluated) if evaluated is not None else None
return agent_config
if isinstance(agent_config, dict):
agent_dict = cast(dict[str, Any], agent_config)
name = agent_dict.get("name")
if name is not None and isinstance(name, str):
# Support dynamic agent name from expression (would need async eval)
if name.startswith("="):
evaluated = state.eval_if_expression(name)
return str(evaluated) if evaluated is not None else None
return str(name)
agent_name = self._action_def.get("agentName")
return str(agent_name) if isinstance(agent_name, str) else None
if isinstance(agent_name, str):
if agent_name.startswith("="):
evaluated = state.eval_if_expression(agent_name)
return str(evaluated) if evaluated is not None else None
return agent_name
return None
def _get_input_config(self) -> tuple[dict[str, Any], Any, str | None, int]:
"""Parse input configuration.
@@ -6,6 +6,7 @@ These executors handle simple actions like SetValue, SendActivity, etc.
Each action becomes a node in the workflow graph.
"""
import uuid
from typing import Any
from agent_framework import (
@@ -80,6 +81,41 @@ class SetVariableExecutor(DeclarativeActionExecutor):
await ctx.send_message(ActionComplete())
class CreateConversationExecutor(DeclarativeActionExecutor):
"""Executor for the CreateConversation action.
Generates a unique conversation ID and initialises a conversation entry
in ``System.conversations``. The generated ID is stored at the state
path specified by the ``conversationId`` parameter (if provided).
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the CreateConversation action."""
state = await self._ensure_state_initialized(ctx, trigger)
generated_id = str(uuid.uuid4())
# Store the generated ID at the requested path (e.g. "Local.myConvId")
conversation_id_path = _get_variable_path(self._action_def, "conversationId")
if conversation_id_path:
state.set(conversation_id_path, generated_id)
# Initialise the conversation entry in System.conversations
conversations: dict[str, Any] = state.get("System.conversations") or {}
conversations[generated_id] = {
"id": generated_id,
"messages": [],
}
state.set("System.conversations", conversations)
await ctx.send_message(ActionComplete())
class SetTextVariableExecutor(DeclarativeActionExecutor):
"""Executor for the SetTextVariable action."""
@@ -560,6 +596,7 @@ class ParseValueExecutor(DeclarativeActionExecutor):
# Mapping of action kinds to executor classes
BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"CreateConversation": CreateConversationExecutor,
"SetValue": SetValueExecutor,
"SetVariable": SetVariableExecutor,
"SetTextVariable": SetTextVariableExecutor,
@@ -496,6 +496,7 @@ class JoinExecutor(DeclarativeActionExecutor):
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Simply pass through to continue the workflow."""
await self._ensure_state_initialized(ctx, trigger)
await ctx.send_message(ActionComplete())
@@ -91,6 +91,7 @@ class WorkflowFactory:
bindings: Mapping[str, Any] | None = None,
env_file: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
max_iterations: int | None = None,
) -> None:
"""Initialize the workflow factory.
@@ -101,6 +102,9 @@ class WorkflowFactory:
bindings: Optional function bindings for tool calls within workflow actions.
env_file: Optional path to .env file for environment variables used in agent creation.
checkpoint_storage: Optional checkpoint storage enabling pause/resume functionality.
max_iterations: Optional maximum runner supersteps. Overrides the YAML ``maxTurns``
field and the core default (100). Workflows with ``GotoAction`` loops (e.g.
DeepResearch) typically need a higher value.
Examples:
.. code-block:: python
@@ -138,6 +142,7 @@ class WorkflowFactory:
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
self._bindings: dict[str, Any] = dict(bindings) if bindings else {}
self._checkpoint_storage = checkpoint_storage
self._max_iterations = max_iterations
def create_workflow_from_yaml_path(
self,
@@ -379,6 +384,7 @@ class WorkflowFactory:
workflow_id=name,
agents=agents,
checkpoint_storage=self._checkpoint_storage,
max_iterations=self._max_iterations,
)
workflow = graph_builder.build()
except ValueError as e:
@@ -1,215 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Action handlers for declarative workflow execution.
This module provides the ActionHandler protocol and registry for executing
workflow actions defined in YAML. Each action type (InvokeAzureAgent, Foreach, etc.)
has a corresponding handler registered via the @action_handler decorator.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from ._state import WorkflowState
logger = logging.getLogger("agent_framework.declarative")
@dataclass
class ActionContext:
"""Context passed to action handlers during execution.
Provides access to workflow state, the action definition, and methods
for executing nested actions (for control flow constructs like Foreach).
"""
state: WorkflowState
"""The current workflow state with variables and agent results."""
action: dict[str, Any]
"""The action definition from the YAML."""
execute_actions: ExecuteActionsFn
"""Function to execute a list of nested actions (for Foreach, If, etc.)."""
agents: dict[str, Any]
"""Registry of agent instances by name."""
bindings: dict[str, Any]
"""Function bindings for tool calls."""
run_kwargs: dict[str, Any] = field(default_factory=dict)
"""Kwargs from workflow.run() to forward to agent invocations."""
@property
def action_id(self) -> str | None:
"""Get the action's unique identifier."""
return self.action.get("id")
@property
def display_name(self) -> str | None:
"""Get the action's human-readable display name for debugging/logging."""
return self.action.get("displayName")
@property
def action_kind(self) -> str | None:
"""Get the action's type/kind."""
return self.action.get("kind")
# Type alias for the nested action executor function
ExecuteActionsFn = Callable[
[list[dict[str, Any]], "WorkflowState"],
AsyncGenerator["WorkflowEvent", None],
]
@dataclass
class WorkflowEvent:
"""Base class for events emitted during workflow execution."""
pass
@dataclass
class TextOutputEvent(WorkflowEvent):
"""Event emitted when text should be sent to the user."""
text: str
"""The text content to output."""
@dataclass
class AttachmentOutputEvent(WorkflowEvent):
"""Event emitted when an attachment should be sent to the user."""
content: Any
"""The attachment content."""
content_type: str = "application/octet-stream"
"""The MIME type of the attachment."""
@dataclass
class AgentResponseEvent(WorkflowEvent):
"""Event emitted when an agent produces a response."""
agent_name: str
"""The name of the agent that produced the response."""
text: str | None
"""The text content of the response, if any."""
messages: list[Any]
"""The messages from the agent response."""
tool_calls: list[Any] | None = None
"""Any tool calls made by the agent."""
@dataclass
class AgentStreamingChunkEvent(WorkflowEvent):
"""Event emitted for streaming chunks from an agent."""
agent_name: str
"""The name of the agent producing the chunk."""
chunk: str
"""The streaming chunk content."""
@dataclass
class CustomEvent(WorkflowEvent):
"""Custom event emitted via EmitEvent action."""
name: str
"""The event name."""
data: Any
"""The event data."""
@dataclass
class LoopControlSignal(WorkflowEvent):
"""Signal for loop control (break/continue)."""
signal_type: str
"""Either 'break' or 'continue'."""
@runtime_checkable
class ActionHandler(Protocol):
"""Protocol for action handlers.
Action handlers are async generators that execute a single action type
and yield events as they process. They receive an ActionContext with
the current state, action definition, and utilities for nested execution.
"""
def __call__(
self,
ctx: ActionContext,
) -> AsyncGenerator[WorkflowEvent]:
"""Execute the action and yield events.
Args:
ctx: The action context containing state, action definition, and utilities
Yields:
WorkflowEvent instances as the action executes
"""
...
# Global registry of action handlers
_ACTION_HANDLERS: dict[str, ActionHandler] = {}
def action_handler(action_kind: str) -> Callable[[ActionHandler], ActionHandler]:
"""Decorator to register an action handler for a specific action type.
Args:
action_kind: The action type this handler processes (e.g., 'InvokeAzureAgent')
Example:
@action_handler("SetValue")
async def handle_set_value(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]:
path = ctx.action.get("path")
value = ctx.state.eval_if_expression(ctx.action.get("value"))
ctx.state.set(path, value)
return
yield # Make it a generator
"""
def decorator(func: ActionHandler) -> ActionHandler:
_ACTION_HANDLERS[action_kind] = func
logger.debug(f"Registered action handler for '{action_kind}'")
return func
return decorator
def get_action_handler(action_kind: str) -> ActionHandler | None:
"""Get the registered handler for an action type.
Args:
action_kind: The action type to look up
Returns:
The registered ActionHandler, or None if not found
"""
return _ACTION_HANDLERS.get(action_kind)
def list_action_handlers() -> list[str]:
"""List all registered action handler types.
Returns:
A list of registered action type names
"""
return list(_ACTION_HANDLERS.keys())
@@ -1,321 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Human-in-the-loop action handlers for declarative workflows.
This module implements handlers for human input patterns:
- Question: Request human input with validation
- RequestExternalInput: Request input from external system
- ExternalLoop processing: Loop while waiting for external input
"""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from ._handlers import (
ActionContext,
WorkflowEvent,
action_handler,
)
if TYPE_CHECKING:
from ._state import WorkflowState
logger = logging.getLogger("agent_framework.declarative")
@dataclass
class QuestionRequest(WorkflowEvent):
"""Event emitted when the workflow needs user input via Question action.
When this event is yielded, the workflow execution should pause
and wait for user input to be provided via workflow.send_response().
This is used by the Question, RequestExternalInput, and WaitForInput
action handlers in the non-graph workflow path.
"""
request_id: str
"""Unique identifier for this request."""
prompt: str | None
"""The prompt/question to display to the user."""
variable: str
"""The variable where the response should be stored."""
validation: dict[str, Any] | None = None
"""Optional validation rules for the input."""
choices: list[str] | None = None
"""Optional list of valid choices."""
default_value: Any = None
"""Default value if no input is provided."""
@dataclass
class ExternalLoopEvent(WorkflowEvent):
"""Event emitted when entering an external input loop.
This event signals that the action is waiting for external input
in a loop pattern (e.g., input.externalLoop.when condition).
"""
action_id: str
"""The ID of the action that requires external input."""
iteration: int
"""The current iteration number (0-based)."""
condition_expression: str
"""The PowerFx condition that must become false to exit the loop."""
@action_handler("Question")
async def handle_question(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Handle Question action - request human input with optional validation.
Action schema:
kind: Question
id: ask_name
variable: Local.userName
prompt: What is your name?
validation:
required: true
minLength: 1
maxLength: 100
choices: # optional - present as multiple choice
- Option A
- Option B
default: Option A # optional default value
The handler emits a QuestionRequest and expects the workflow runner
to capture and provide the response before continuing.
"""
question_id = ctx.action.get("id", "question")
variable = ctx.action.get("variable")
prompt = ctx.action.get("prompt")
question: dict[str, Any] | Any = ctx.action.get("question", {})
validation = ctx.action.get("validation", {})
choices = ctx.action.get("choices")
default_value = ctx.action.get("default")
if not variable:
logger.warning("Question action missing 'variable' property")
return
# Evaluate prompt if it's an expression (support both 'prompt' and 'question.text')
prompt_text: Any | None = None
if isinstance(question, dict):
question_dict: dict[str, Any] = cast(dict[str, Any], question)
prompt_text = prompt or question_dict.get("text")
else:
prompt_text = prompt
evaluated_prompt = ctx.state.eval_if_expression(prompt_text) if prompt_text else None
# Evaluate choices if they're expressions
evaluated_choices = None
if choices:
evaluated_choices = [ctx.state.eval_if_expression(c) if isinstance(c, str) else c for c in choices]
logger.debug(f"Question: requesting input for {variable}")
# Emit the request event
yield QuestionRequest(
request_id=question_id,
prompt=str(evaluated_prompt) if evaluated_prompt else None,
variable=variable,
validation=validation,
choices=evaluated_choices,
default_value=default_value,
)
# Apply default value if specified (for non-interactive scenarios)
if default_value is not None:
evaluated_default = ctx.state.eval_if_expression(default_value)
ctx.state.set(variable, evaluated_default)
@action_handler("RequestExternalInput")
async def handle_request_external_input(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Handle RequestExternalInput action - request input from external system.
Action schema:
kind: RequestExternalInput
id: get_approval
variable: Local.approval
prompt: Please approve or reject the request
timeout: 300 # seconds
default: "No feedback provided" # optional default value
output:
response: Local.approvalResponse
timestamp: Local.approvalTime
Similar to Question but designed for external system integration
rather than direct human input.
"""
request_id = ctx.action.get("id", "external_input")
variable = ctx.action.get("variable")
prompt = ctx.action.get("prompt")
timeout = ctx.action.get("timeout") # seconds
default_value = ctx.action.get("default")
_output = ctx.action.get("output", {}) # Reserved for future use
if not variable:
logger.warning("RequestExternalInput action missing 'variable' property")
return
# Extract prompt text (support both 'prompt' string and 'prompt.text' object)
prompt_text: Any | None = None
if isinstance(prompt, dict):
prompt_dict: dict[str, Any] = cast(dict[str, Any], prompt)
prompt_text = prompt_dict.get("text")
else:
prompt_text = prompt
# Evaluate prompt if it's an expression
evaluated_prompt = ctx.state.eval_if_expression(prompt_text) if prompt_text else None
logger.debug(f"RequestExternalInput: requesting input for {variable}")
# Emit the request event
yield QuestionRequest(
request_id=request_id,
prompt=str(evaluated_prompt) if evaluated_prompt else None,
variable=variable,
validation={"timeout": timeout} if timeout else None,
default_value=default_value,
)
# Apply default value if specified (for non-interactive scenarios)
if default_value is not None:
evaluated_default = ctx.state.eval_if_expression(default_value)
ctx.state.set(variable, evaluated_default)
@action_handler("WaitForInput")
async def handle_wait_for_input(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent]: # noqa: RUF029
"""Handle WaitForInput action - pause and wait for external input.
Action schema:
kind: WaitForInput
id: wait_for_response
variable: Local.response
message: Waiting for user response...
This is a simpler form of RequestExternalInput that just pauses
execution until input is provided.
"""
wait_id = ctx.action.get("id", "wait")
variable = ctx.action.get("variable")
message = ctx.action.get("message")
if not variable:
logger.warning("WaitForInput action missing 'variable' property")
return
# Evaluate message if it's an expression
evaluated_message = ctx.state.eval_if_expression(message) if message else None
logger.debug(f"WaitForInput: waiting for {variable}")
yield QuestionRequest(
request_id=wait_id,
prompt=str(evaluated_message) if evaluated_message else None,
variable=variable,
)
def process_external_loop(
input_config: dict[str, Any],
state: WorkflowState,
) -> tuple[bool, str | None]:
"""Process the externalLoop.when pattern from action input.
This function evaluates the externalLoop.when condition to determine
if the action should continue looping for external input.
Args:
input_config: The input configuration containing externalLoop
state: The workflow state for expression evaluation
Returns:
Tuple of (should_continue_loop, condition_expression)
- should_continue_loop: True if the loop should continue
- condition_expression: The original condition expression for diagnostics
"""
external_loop = input_config.get("externalLoop", {})
when_condition = external_loop.get("when")
if not when_condition:
return (False, None)
# Evaluate the condition
result = state.eval(when_condition)
# The loop continues while the condition is True
should_continue = bool(result) if result is not None else False
logger.debug(f"ExternalLoop condition '{when_condition[:50]}' evaluated to {should_continue}")
return (should_continue, when_condition)
def validate_input_response(
value: Any,
validation: dict[str, Any] | None,
) -> tuple[bool, str | None]:
"""Validate input response against validation rules.
Args:
value: The input value to validate
validation: Validation rules from the Question action
Returns:
Tuple of (is_valid, error_message)
"""
if not validation:
return (True, None)
# Check required
if validation.get("required") and (value is None or value == ""):
return (False, "This field is required")
if value is None:
return (True, None)
# Check string length
if isinstance(value, str):
min_length = validation.get("minLength")
max_length = validation.get("maxLength")
if min_length is not None and len(value) < min_length:
return (False, f"Minimum length is {min_length}")
if max_length is not None and len(value) > max_length:
return (False, f"Maximum length is {max_length}")
# Check numeric range
if isinstance(value, (int, float)):
min_value = validation.get("min")
max_value = validation.get("max")
if min_value is not None and value < min_value:
return (False, f"Minimum value is {min_value}")
if max_value is not None and value > max_value:
return (False, f"Maximum value is {max_value}")
# Check pattern (regex)
pattern = validation.get("pattern")
if pattern and isinstance(value, str):
import re
if not re.match(pattern, value):
return (False, f"Value does not match pattern: {pattern}")
return (True, None)
@@ -12,6 +12,7 @@ This module provides state management for declarative workflows, handling:
from __future__ import annotations
import logging
import uuid
from collections.abc import Mapping
from typing import Any, cast
@@ -107,11 +108,15 @@ class WorkflowState:
self._inputs: dict[str, Any] = dict(inputs) if inputs else {}
self._local: dict[str, Any] = {}
self._outputs: dict[str, Any] = {}
conversation_id = str(uuid.uuid4())
self._system: dict[str, Any] = {
"ConversationId": "default",
"ConversationId": conversation_id,
"LastMessage": {"Text": "", "Id": ""},
"LastMessageText": "",
"LastMessageId": "",
"conversations": {
conversation_id: {"id": conversation_id, "messages": []},
},
}
self._agent: dict[str, Any] = {}
self._conversation: dict[str, Any] = {