[BREAKING] Python: Refactor SharedState to State with sync methods and superstep caching (#3667)

* Refactor SharedState to State with sync methods and superstep caching

* Fixes

* Address PR feedback

* Remove dead links

* Fix lab test import
This commit is contained in:
Evan Mattson
2026-02-05 10:42:52 +09:00
committed by GitHub
Unverified
parent 4e25917644
commit 10afb86213
48 changed files with 1971 additions and 1724 deletions
@@ -3,7 +3,7 @@
"""Base classes for graph-based declarative workflow executors.
This module provides:
- DeclarativeWorkflowState: Manages workflow variables via SharedState
- DeclarativeWorkflowState: Manages workflow variables via State
- DeclarativeActionExecutor: Base class for action executors
- Message types for inter-executor communication
@@ -34,7 +34,7 @@ from agent_framework._workflows import (
Executor,
WorkflowContext,
)
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
from powerfx import Engine
if sys.version_info >= (3, 11):
@@ -61,10 +61,10 @@ class ConversationData(TypedDict):
class DeclarativeStateData(TypedDict, total=False):
"""Structure for the declarative workflow state stored in SharedState.
"""Structure for the declarative workflow state stored in State.
This TypedDict defines the schema for workflow variables stored
under the DECLARATIVE_STATE_KEY in SharedState.
under the DECLARATIVE_STATE_KEY in State.
Variable Scopes (matching .NET naming conventions):
Inputs: Initial workflow inputs (read-only after initialization).
@@ -87,7 +87,7 @@ class DeclarativeStateData(TypedDict, total=False):
_declarative_loop_state: dict[str, Any]
# Key used in SharedState to store declarative workflow variables
# Key used in State to store declarative workflow variables
DECLARATIVE_STATE_KEY = "_declarative_workflow_state"
@@ -126,10 +126,10 @@ def _make_powerfx_safe(value: Any) -> Any:
class DeclarativeWorkflowState:
"""Manages workflow variables stored in SharedState.
"""Manages workflow variables stored in State.
This class provides the same interface as the interpreter-based WorkflowState
but stores all data in SharedState for checkpointing support.
but stores all data in State for checkpointing support.
The state is organized into namespaces (matching .NET naming conventions):
- Workflow.Inputs: Initial inputs (read-only)
@@ -140,15 +140,15 @@ class DeclarativeWorkflowState:
- Conversation: Conversation history
"""
def __init__(self, shared_state: SharedState):
"""Initialize with a SharedState instance.
def __init__(self, state: State):
"""Initialize with a State instance.
Args:
shared_state: The workflow's shared state for persistence
state: The workflow's state for persistence
"""
self._shared_state = shared_state
self._state = state
async def initialize(self, inputs: "Mapping[str, Any] | None" = None) -> None:
def initialize(self, inputs: "Mapping[str, Any] | None" = None) -> None:
"""Initialize the declarative state with inputs.
Args:
@@ -168,23 +168,22 @@ class DeclarativeWorkflowState:
"Conversation": {"messages": [], "history": []},
"Custom": {},
}
await self._shared_state.set(DECLARATIVE_STATE_KEY, state_data)
self._state.set(DECLARATIVE_STATE_KEY, state_data)
async def get_state_data(self) -> DeclarativeStateData:
"""Get the full state data dict from shared state."""
try:
result: DeclarativeStateData = await self._shared_state.get(DECLARATIVE_STATE_KEY)
return result
except KeyError:
def get_state_data(self) -> DeclarativeStateData:
"""Get the full state data dict from state."""
result = self._state.get(DECLARATIVE_STATE_KEY)
if result is None:
# Initialize if not present
await self.initialize()
return cast(DeclarativeStateData, await self._shared_state.get(DECLARATIVE_STATE_KEY))
self.initialize()
result = self._state.get(DECLARATIVE_STATE_KEY)
return cast(DeclarativeStateData, result)
async def set_state_data(self, data: DeclarativeStateData) -> None:
"""Set the full state data dict in shared state."""
await self._shared_state.set(DECLARATIVE_STATE_KEY, data)
def set_state_data(self, data: DeclarativeStateData) -> None:
"""Set the full state data dict in state."""
self._state.set(DECLARATIVE_STATE_KEY, data)
async def get(self, path: str, default: Any = None) -> Any:
def get(self, path: str, default: Any = None) -> Any:
"""Get a value from the state using a dot-notated path.
Args:
@@ -194,7 +193,7 @@ class DeclarativeWorkflowState:
Returns:
The value at the path, or default if not found
"""
state_data = await self.get_state_data()
state_data = self.get_state_data()
parts = path.split(".")
if not parts:
return default
@@ -240,7 +239,7 @@ class DeclarativeWorkflowState:
return obj # type: ignore[return-value]
async def set(self, path: str, value: Any) -> None:
def set(self, path: str, value: Any) -> None:
"""Set a value in the state using a dot-notated path.
Args:
@@ -250,7 +249,7 @@ class DeclarativeWorkflowState:
Raises:
ValueError: If attempting to set Workflow.Inputs (which is read-only)
"""
state_data = await self.get_state_data()
state_data = self.get_state_data()
parts = path.split(".")
if not parts:
return
@@ -296,9 +295,9 @@ class DeclarativeWorkflowState:
# Set the final value
target[remaining[-1]] = value
await self.set_state_data(state_data)
self.set_state_data(state_data)
async def append(self, path: str, value: Any) -> None:
def append(self, path: str, value: Any) -> None:
"""Append a value to a list at the specified path.
If the path doesn't exist, creates a new list with the value.
@@ -310,17 +309,17 @@ class DeclarativeWorkflowState:
path: Dot-notated path to a list
value: The value to append
"""
existing = await self.get(path)
existing = self.get(path)
if existing is None:
await self.set(path, [value])
self.set(path, [value])
elif isinstance(existing, list):
existing_list: list[Any] = list(existing) # type: ignore[arg-type]
existing_list.append(value)
await self.set(path, existing_list)
self.set(path, existing_list)
else:
raise ValueError(f"Cannot append to non-list at path '{path}'")
async def eval(self, expression: str) -> Any:
def eval(self, expression: str) -> Any:
"""Evaluate a PowerFx expression with the current state.
Expressions starting with '=' are evaluated as PowerFx.
@@ -354,16 +353,16 @@ class DeclarativeWorkflowState:
# Handle custom functions not supported by PowerFx
# First check if the entire formula is a custom function
result = await self._eval_custom_function(formula)
result = self._eval_custom_function(formula)
if result is not None:
return result
# Pre-process nested custom functions (e.g., Upper(MessageText(...)))
# Replace them with their evaluated results before sending to PowerFx
formula = await self._preprocess_custom_functions(formula)
formula = self._preprocess_custom_functions(formula)
engine = Engine()
symbols = await self._to_powerfx_symbols()
symbols = self._to_powerfx_symbols()
try:
return engine.eval(formula, symbols=symbols)
except ValueError as e:
@@ -375,7 +374,7 @@ class DeclarativeWorkflowState:
return None
raise
async def _eval_custom_function(self, formula: str) -> Any | None:
def _eval_custom_function(self, formula: str) -> Any | None:
"""Handle custom functions not supported by the Python PowerFx library.
The standard PowerFx library supports these functions but the Python wrapper
@@ -404,7 +403,7 @@ class DeclarativeWorkflowState:
evaluated_args.append(arg[1:-1])
else:
# Variable reference - evaluate it
result = await self.eval(f"={arg}")
result = self.eval(f"={arg}")
evaluated_args.append(str(result) if result is not None else "")
return "".join(evaluated_args)
@@ -413,14 +412,14 @@ class DeclarativeWorkflowState:
if match:
inner_expr = match.group(1).strip()
# Evaluate the inner expression
text = await self.eval(f"={inner_expr}")
text = self.eval(f"={inner_expr}")
return {"role": "user", "text": str(text) if text else ""}
# AgentMessage(expr) - creates an assistant message dict
match = re.match(r"AgentMessage\((.+)\)$", formula.strip())
if match:
inner_expr = match.group(1).strip()
text = await self.eval(f"={inner_expr}")
text = self.eval(f"={inner_expr}")
return {"role": "assistant", "text": str(text) if text else ""}
# MessageText(expr) - extracts text from the last message
@@ -428,11 +427,11 @@ class DeclarativeWorkflowState:
if match:
inner_expr = match.group(1).strip()
# Reuse the helper method for consistent text extraction
return await self._eval_and_replace_message_text(inner_expr)
return self._eval_and_replace_message_text(inner_expr)
return None
async def _preprocess_custom_functions(self, formula: str) -> str:
def _preprocess_custom_functions(self, formula: str) -> str:
"""Pre-process custom functions nested inside other PowerFx functions.
Custom functions like MessageText() are not supported by the PowerFx engine.
@@ -509,7 +508,7 @@ class DeclarativeWorkflowState:
inner_expr = formula[paren_start + 1 : end - 1]
# Evaluate and get replacement
replacement = await handler(inner_expr)
replacement = handler(inner_expr)
# Replace in formula
if isinstance(replacement, str):
@@ -517,7 +516,7 @@ class DeclarativeWorkflowState:
# Store long strings in a temp variable to avoid PowerFx expression limit
temp_var_name = f"_TempMessageText{temp_var_counter}"
temp_var_counter += 1
await self.set(f"Local.{temp_var_name}", replacement)
self.set(f"Local.{temp_var_name}", replacement)
replacement_str = f"Local.{temp_var_name}"
logger.debug(
f"Stored long MessageText result ({len(replacement)} chars) "
@@ -534,7 +533,7 @@ class DeclarativeWorkflowState:
return formula
async def _eval_and_replace_message_text(self, inner_expr: str) -> str:
def _eval_and_replace_message_text(self, inner_expr: str) -> str:
"""Evaluate MessageText() and return the text result.
Args:
@@ -543,7 +542,7 @@ class DeclarativeWorkflowState:
Returns:
The extracted text from the messages
"""
messages: Any = await self.eval(f"={inner_expr}")
messages: Any = self.eval(f"={inner_expr}")
if isinstance(messages, list) and messages:
last_msg: Any = messages[-1]
if isinstance(last_msg, dict):
@@ -603,13 +602,13 @@ class DeclarativeWorkflowState:
return args
async def _to_powerfx_symbols(self) -> dict[str, Any]:
def _to_powerfx_symbols(self) -> dict[str, Any]:
"""Convert the current state to a PowerFx symbols dictionary.
Uses .NET-style PascalCase names (System, Local, Workflow) matching
the .NET declarative workflow implementation.
"""
state_data = await self.get_state_data()
state_data = self.get_state_data()
local_data = state_data.get("Local", {})
agent_data = state_data.get("Agent", {})
conversation_data = state_data.get("Conversation", {})
@@ -642,19 +641,19 @@ class DeclarativeWorkflowState:
result = _make_powerfx_safe(symbols)
return cast(dict[str, Any], result)
async def eval_if_expression(self, value: Any) -> Any:
def eval_if_expression(self, value: Any) -> Any:
"""Evaluate a value if it's a PowerFx expression, otherwise return as-is."""
if isinstance(value, str):
return await self.eval(value)
return self.eval(value)
if isinstance(value, dict):
value_dict: dict[str, Any] = dict(value) # type: ignore[arg-type]
return {k: await self.eval_if_expression(v) for k, v in value_dict.items()}
return {k: self.eval_if_expression(v) for k, v in value_dict.items()}
if isinstance(value, list):
value_list: list[Any] = list(value) # type: ignore[arg-type]
return [await self.eval_if_expression(item) for item in value_list]
return [self.eval_if_expression(item) for item in value_list]
return value
async def interpolate_string(self, text: str) -> str:
def interpolate_string(self, text: str) -> str:
"""Interpolate {Variable.Path} references in a string.
This handles template-style variable substitution like:
@@ -669,18 +668,18 @@ class DeclarativeWorkflowState:
"""
import re
async def replace_var(match: re.Match[str]) -> str:
def replace_var(match: re.Match[str]) -> str:
var_path: str = match.group(1)
value = await self.get(var_path)
value = self.get(var_path)
return str(value) if value is not None else ""
# Match {Variable.Path} patterns
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
# re.sub doesn't support async, so we need to do it manually
# Replace all matches
result = text
for match in re.finditer(pattern, text):
replacement = await replace_var(match)
replacement = replace_var(match)
result = result.replace(match.group(0), replacement, 1)
return result
@@ -802,9 +801,9 @@ class DeclarativeActionExecutor(Executor):
"""Get the display name for logging."""
return self._action_def.get("displayName")
def _get_state(self, shared_state: SharedState) -> DeclarativeWorkflowState:
def _get_state(self, state: State) -> DeclarativeWorkflowState:
"""Get the declarative workflow state wrapper."""
return DeclarativeWorkflowState(shared_state)
return DeclarativeWorkflowState(state)
async def _ensure_state_initialized(
self,
@@ -826,18 +825,18 @@ class DeclarativeActionExecutor(Executor):
Returns:
The initialized DeclarativeWorkflowState
"""
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
if isinstance(trigger, dict):
# Structured inputs - use directly
await state.initialize(trigger) # type: ignore
state.initialize(trigger) # type: ignore
elif isinstance(trigger, str):
# String input - wrap in dict
await state.initialize({"input": trigger})
state.initialize({"input": trigger})
elif not isinstance(
trigger, (ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl)
):
# Any other type - convert to string like .NET's DefaultTransform
await state.initialize({"input": str(trigger)})
state.initialize({"input": str(trigger)})
return state
@@ -348,7 +348,7 @@ class AgentExternalInputResponse:
class ExternalLoopState:
"""State saved for external loop resumption.
Stored in shared_state to allow the response_handler to
Stored in workflow state to allow the response_handler to
continue the loop with the same configuration.
"""
@@ -534,7 +534,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
return "Conversation.messages"
# Evaluate the conversation ID expression
evaluated_id = await state.eval_if_expression(conversation_id_expr)
evaluated_id = state.eval_if_expression(conversation_id_expr)
if not evaluated_id:
return "Conversation.messages"
@@ -555,11 +555,11 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
# Evaluate arguments
evaluated_args: dict[str, Any] = {}
for key, value in arguments.items():
evaluated_args[key] = await state.eval_if_expression(value)
evaluated_args[key] = state.eval_if_expression(value)
# Evaluate messages/input
if messages_expr:
evaluated_input: Any = await state.eval_if_expression(messages_expr)
evaluated_input: Any = state.eval_if_expression(messages_expr)
if isinstance(evaluated_input, str):
return evaluated_input
if isinstance(evaluated_input, list) and evaluated_input:
@@ -581,17 +581,17 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
# 1. Local.input / Local.userInput (explicit turn state)
# 2. System.LastMessage.Text (previous agent's response)
# 3. Workflow.Inputs (first agent gets workflow inputs)
input_text: str = str(await state.get("Local.input") or await state.get("Local.userInput") or "")
input_text: str = str(state.get("Local.input") or state.get("Local.userInput") or "")
if not input_text:
# Try System.LastMessage.Text (used by external loop and agent chaining)
last_message: Any = await state.get("System.LastMessage")
last_message: Any = state.get("System.LastMessage")
if isinstance(last_message, dict):
last_msg_dict = cast(dict[str, Any], last_message)
text_val: Any = last_msg_dict.get("Text", "")
input_text = str(text_val) if text_val else ""
if not input_text:
# Fall back to workflow inputs (for first agent in chain)
inputs: Any = await state.get("Workflow.Inputs")
inputs: Any = state.get("Workflow.Inputs")
if isinstance(inputs, dict):
inputs_dict = cast(dict[str, Any], inputs)
# If single input, use its value directly
@@ -642,12 +642,12 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
# Add user input to conversation history first (via state.append only)
if input_text:
user_message = ChatMessage("user", [input_text])
await state.append(messages_path, user_message)
user_message = ChatMessage(role="user", text=input_text)
state.append(messages_path, user_message)
# Get conversation history from state AFTER adding user message
# Note: We get a fresh copy to avoid mutation issues
conversation_history: list[ChatMessage] = await state.get(messages_path) or []
conversation_history: list[ChatMessage] = state.get(messages_path) or []
# Build messages list for agent (use history if available, otherwise just input)
messages_for_agent: list[ChatMessage] | str = conversation_history if conversation_history else input_text
@@ -704,32 +704,32 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
role,
content_types,
)
await state.append(messages_path, msg)
state.append(messages_path, msg)
elif accumulated_response:
# No messages returned, create a simple assistant message
logger.debug(
"Agent '%s': No messages in response, creating simple assistant message",
agent_name,
)
assistant_message = ChatMessage("assistant", [accumulated_response])
await state.append(messages_path, assistant_message)
assistant_message = ChatMessage(role="assistant", text=accumulated_response)
state.append(messages_path, assistant_message)
# Store results in state - support both schema formats:
# - Graph mode: Agent.response, Agent.name
# - Interpreter mode: Agent.text, Agent.messages, Agent.toolCalls
await state.set("Agent.response", accumulated_response)
await state.set("Agent.name", agent_name)
await state.set("Agent.text", accumulated_response)
await state.set("Agent.messages", all_messages if all_messages else [])
await state.set("Agent.toolCalls", tool_calls if tool_calls else [])
state.set("Agent.response", accumulated_response)
state.set("Agent.name", agent_name)
state.set("Agent.text", accumulated_response)
state.set("Agent.messages", all_messages if all_messages else [])
state.set("Agent.toolCalls", tool_calls if tool_calls else [])
# Store System.LastMessage for externalLoop.when condition evaluation
await state.set("System.LastMessage", {"Text": accumulated_response})
state.set("System.LastMessage", {"Text": accumulated_response})
# Store in output variables (.NET style)
if messages_var:
output_path = _normalize_variable_path(messages_var)
await state.set(output_path, all_messages if all_messages else accumulated_response)
state.set(output_path, all_messages if all_messages else accumulated_response)
if response_obj_var:
output_path = _normalize_variable_path(response_obj_var)
@@ -737,14 +737,14 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
try:
parsed = _extract_json_from_response(accumulated_response) if accumulated_response else None
logger.debug(f"InvokeAzureAgent: parsed responseObject for '{output_path}': type={type(parsed)}")
await state.set(output_path, parsed)
state.set(output_path, parsed)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"InvokeAzureAgent: failed to parse JSON for '{output_path}': {e}, storing as string")
await state.set(output_path, accumulated_response)
state.set(output_path, accumulated_response)
# Store in result property (Python style)
if result_property:
await state.set(result_property, accumulated_response)
state.set(result_property, accumulated_response)
return accumulated_response, all_messages, tool_calls
@@ -788,7 +788,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
agent: Any = self._agents.get(agent_name) if self._agents else None
if agent is None:
try:
agent_registry: dict[str, Any] | None = await ctx.shared_state.get(AGENT_REGISTRY_KEY)
agent_registry: dict[str, Any] | None = ctx.state.get(AGENT_REGISTRY_KEY)
except KeyError:
agent_registry = {}
agent = agent_registry.get(agent_name) if agent_registry else None
@@ -796,9 +796,9 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
if agent is None:
error_msg = f"Agent '{agent_name}' not found in registry"
logger.error(f"InvokeAzureAgent: {error_msg}")
await state.set("Agent.error", error_msg)
state.set("Agent.error", error_msg)
if result_property:
await state.set(result_property, {"error": error_msg})
state.set(result_property, {"error": error_msg})
raise AgentInvocationError(agent_name, "not found in registry")
iteration = 0
@@ -820,14 +820,14 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
raise # Re-raise our own errors
except Exception as e:
logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}': {e}")
await state.set("Agent.error", str(e))
state.set("Agent.error", str(e))
if result_property:
await state.set(result_property, {"error": str(e)})
state.set(result_property, {"error": str(e)})
raise AgentInvocationError(agent_name, str(e)) from e
# Check external loop condition
if external_loop_when:
should_continue = await state.eval(external_loop_when)
should_continue = state.eval(external_loop_when)
should_continue = bool(should_continue) if should_continue is not None else False
logger.debug(
@@ -848,7 +848,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
messages_path=messages_path,
max_iterations=max_iterations,
)
await ctx.shared_state.set(EXTERNAL_LOOP_STATE_KEY, loop_state)
ctx.state.set(EXTERNAL_LOOP_STATE_KEY, loop_state)
# Emit request for external input - workflow will yield here
request = AgentExternalInputRequest(
@@ -883,12 +883,11 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
"handle_external_input_response: resuming with user_input='%s'",
response.user_input[:100] if response.user_input else None,
)
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
# Retrieve saved loop state
try:
loop_state: ExternalLoopState = await ctx.shared_state.get(EXTERNAL_LOOP_STATE_KEY)
except KeyError:
loop_state: ExternalLoopState | None = ctx.state.get(EXTERNAL_LOOP_STATE_KEY)
if loop_state is None:
logger.error("InvokeAzureAgent: external loop state not found, cannot resume")
await ctx.send_message(ActionComplete())
return
@@ -910,12 +909,12 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
input_text = response.user_input
# Store the user input in state for condition evaluation
await state.set("Local.userInput", input_text)
await state.set("System.LastMessage", {"Text": input_text})
state.set("Local.userInput", input_text)
state.set("System.LastMessage", {"Text": input_text})
# Check if we should continue BEFORE invoking the agent
# This matches .NET behavior where the condition checks the user's input
should_continue = await state.eval(external_loop_when)
should_continue = state.eval(external_loop_when)
should_continue = bool(should_continue) if should_continue is not None else False
logger.debug(
@@ -926,7 +925,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
if not should_continue:
# User input caused loop to exit - clean up and complete
with contextlib.suppress(KeyError):
await ctx.shared_state.delete(EXTERNAL_LOOP_STATE_KEY)
ctx.state.delete(EXTERNAL_LOOP_STATE_KEY)
await ctx.send_message(ActionComplete())
return
@@ -934,7 +933,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
agent: Any = self._agents.get(agent_name) if self._agents else None
if agent is None:
try:
agent_registry: dict[str, Any] | None = await ctx.shared_state.get(AGENT_REGISTRY_KEY)
agent_registry: dict[str, Any] | None = ctx.state.get(AGENT_REGISTRY_KEY)
except KeyError:
agent_registry = {}
agent = agent_registry.get(agent_name) if agent_registry else None
@@ -960,12 +959,12 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
raise # Re-raise our own errors
except Exception as e:
logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}' during loop: {e}")
await state.set("Agent.error", str(e))
state.set("Agent.error", str(e))
raise AgentInvocationError(agent_name, str(e)) from e
# Re-evaluate the condition AFTER the agent responds
# This is critical: the agent's response may have set NeedsTicket=true or IsResolved=true
should_continue = await state.eval(external_loop_when)
should_continue = state.eval(external_loop_when)
should_continue = bool(should_continue) if should_continue is not None else False
logger.debug(
@@ -980,7 +979,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
"(sending ActionComplete to continue workflow)"
)
with contextlib.suppress(KeyError):
await ctx.shared_state.delete(EXTERNAL_LOOP_STATE_KEY)
ctx.state.delete(EXTERNAL_LOOP_STATE_KEY)
await ctx.send_message(ActionComplete())
return
@@ -988,7 +987,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
if iteration < max_iterations:
# Update loop state for next iteration
loop_state.iteration = iteration + 1
await ctx.shared_state.set(EXTERNAL_LOOP_STATE_KEY, loop_state)
ctx.state.set(EXTERNAL_LOOP_STATE_KEY, loop_state)
# Emit another request for external input
request = AgentExternalInputRequest(
@@ -1007,7 +1006,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
# Loop complete - clean up and send completion
with contextlib.suppress(KeyError):
await ctx.shared_state.delete(EXTERNAL_LOOP_STATE_KEY)
ctx.state.delete(EXTERNAL_LOOP_STATE_KEY)
await ctx.send_message(ActionComplete())
@@ -1035,7 +1034,7 @@ class InvokeToolExecutor(DeclarativeActionExecutor):
# Get tools registry
try:
tool_registry: dict[str, Any] | None = await ctx.shared_state.get(TOOL_REGISTRY_KEY)
tool_registry: dict[str, Any] | None = ctx.state.get(TOOL_REGISTRY_KEY)
except KeyError:
tool_registry = {}
@@ -1044,18 +1043,18 @@ class InvokeToolExecutor(DeclarativeActionExecutor):
if tool is None:
error_msg = f"Tool '{tool_name}' not found in registry"
if output_property:
await state.set(output_property, {"error": error_msg})
state.set(output_property, {"error": error_msg})
await ctx.send_message(ActionComplete())
return
# Build parameters
params: dict[str, Any] = {}
for param_name, param_expression in parameters.items():
params[param_name] = await state.eval_if_expression(param_expression)
params[param_name] = state.eval_if_expression(param_expression)
# Add main input if specified
if input_expr:
params["input"] = await state.eval_if_expression(input_expr)
params["input"] = state.eval_if_expression(input_expr)
try:
# Invoke the tool
@@ -1068,11 +1067,11 @@ class InvokeToolExecutor(DeclarativeActionExecutor):
# Store result
if output_property:
await state.set(output_property, result)
state.set(output_property, result)
except Exception as e:
if output_property:
await state.set(output_property, {"error": str(e)})
state.set(output_property, {"error": str(e)})
await ctx.send_message(ActionComplete())
return
@@ -52,8 +52,8 @@ class SetValueExecutor(DeclarativeActionExecutor):
if path:
# Evaluate value if it's an expression
evaluated_value = await state.eval_if_expression(value)
await state.set(path, evaluated_value)
evaluated_value = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
@@ -74,8 +74,8 @@ class SetVariableExecutor(DeclarativeActionExecutor):
value = self._action_def.get("value")
if path:
evaluated_value = await state.eval_if_expression(value)
await state.set(path, evaluated_value)
evaluated_value = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
@@ -96,8 +96,8 @@ class SetTextVariableExecutor(DeclarativeActionExecutor):
text = self._action_def.get("text", "")
if path:
evaluated_text = await state.eval_if_expression(text)
await state.set(path, str(evaluated_text) if evaluated_text is not None else "")
evaluated_text = state.eval_if_expression(text)
state.set(path, str(evaluated_text) if evaluated_text is not None else "")
await ctx.send_message(ActionComplete())
@@ -126,8 +126,8 @@ class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
path = assignment.get("path")
value = assignment.get("value")
if path:
evaluated_value = await state.eval_if_expression(value)
await state.set(path, evaluated_value)
evaluated_value = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
@@ -148,8 +148,8 @@ class AppendValueExecutor(DeclarativeActionExecutor):
value = self._action_def.get("value")
if path:
evaluated_value = await state.eval_if_expression(value)
await state.append(path, evaluated_value)
evaluated_value = state.eval_if_expression(value)
state.append(path, evaluated_value)
await ctx.send_message(ActionComplete())
@@ -170,7 +170,7 @@ class ResetVariableExecutor(DeclarativeActionExecutor):
if path:
# Reset to None/empty
await state.set(path, None)
state.set(path, None)
await ctx.send_message(ActionComplete())
@@ -188,9 +188,9 @@ class ClearAllVariablesExecutor(DeclarativeActionExecutor):
state = await self._ensure_state_initialized(ctx, trigger)
# Get state data and clear Local variables
state_data = await state.get_state_data()
state_data = state.get_state_data()
state_data["Local"] = {}
await state.set_state_data(state_data)
state.set_state_data(state_data)
await ctx.send_message(ActionComplete())
@@ -217,10 +217,10 @@ class SendActivityExecutor(DeclarativeActionExecutor):
if isinstance(text, str):
# First evaluate any =expression syntax
text = await state.eval_if_expression(text)
text = state.eval_if_expression(text)
# Then interpolate any {Variable.Path} template syntax
if isinstance(text, str):
text = await state.interpolate_string(text)
text = state.interpolate_string(text)
# Yield the text as workflow output
if text:
@@ -258,8 +258,8 @@ class EmitEventExecutor(DeclarativeActionExecutor):
event_value = event_def.get("data")
if event_name:
evaluated_name = await state.eval_if_expression(event_name)
evaluated_value = await state.eval_if_expression(event_value)
evaluated_name = state.eval_if_expression(event_name)
evaluated_value = state.eval_if_expression(event_value)
event_data = {
"eventName": evaluated_name,
@@ -300,16 +300,16 @@ class EditTableExecutor(DeclarativeActionExecutor):
if table_path:
# Get current table value
current_table = await state.get(table_path)
current_table = state.get(table_path)
if current_table is None:
current_table = []
elif not isinstance(current_table, list):
current_table = [current_table]
if operation == "add" or operation == "insert":
evaluated_value = await state.eval_if_expression(value)
evaluated_value = state.eval_if_expression(value)
if index is not None:
evaluated_index = await state.eval_if_expression(index)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
current_table.insert(idx, evaluated_value)
else:
@@ -318,12 +318,12 @@ class EditTableExecutor(DeclarativeActionExecutor):
elif operation == "remove":
if value is not None:
# Remove by value
evaluated_value = await state.eval_if_expression(value)
evaluated_value = state.eval_if_expression(value)
if evaluated_value in current_table:
current_table.remove(evaluated_value)
elif index is not None:
# Remove by index
evaluated_index = await state.eval_if_expression(index)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else -1
if 0 <= idx < len(current_table):
current_table.pop(idx)
@@ -334,13 +334,13 @@ class EditTableExecutor(DeclarativeActionExecutor):
elif operation == "set" or operation == "update":
# Update item at index
if index is not None:
evaluated_value = await state.eval_if_expression(value)
evaluated_index = await state.eval_if_expression(index)
evaluated_value = state.eval_if_expression(value)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else 0
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_value
await state.set(table_path, current_table)
state.set(table_path, current_table)
await ctx.send_message(ActionComplete())
@@ -377,16 +377,16 @@ class EditTableV2Executor(DeclarativeActionExecutor):
if table_path:
# Get current table value
current_table = await state.get(table_path)
current_table = state.get(table_path)
if current_table is None:
current_table = []
elif not isinstance(current_table, list):
current_table = [current_table]
if operation == "add":
evaluated_item = await state.eval_if_expression(item)
evaluated_item = state.eval_if_expression(item)
if index is not None:
evaluated_index = await state.eval_if_expression(index)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
current_table.insert(idx, evaluated_item)
else:
@@ -394,7 +394,7 @@ class EditTableV2Executor(DeclarativeActionExecutor):
elif operation == "remove":
if item is not None:
evaluated_item = await state.eval_if_expression(item)
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
# Remove by key match
key_value = evaluated_item.get(key_field)
@@ -404,7 +404,7 @@ class EditTableV2Executor(DeclarativeActionExecutor):
elif evaluated_item in current_table:
current_table.remove(evaluated_item)
elif index is not None:
evaluated_index = await state.eval_if_expression(index)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else -1
if 0 <= idx < len(current_table):
current_table.pop(idx)
@@ -413,7 +413,7 @@ class EditTableV2Executor(DeclarativeActionExecutor):
current_table = []
elif operation == "addorupdate":
evaluated_item = await state.eval_if_expression(item)
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field)
# Find existing item with same key
@@ -433,9 +433,9 @@ class EditTableV2Executor(DeclarativeActionExecutor):
current_table.append(evaluated_item)
elif operation == "update":
evaluated_item = await state.eval_if_expression(item)
evaluated_item = state.eval_if_expression(item)
if index is not None:
evaluated_index = await state.eval_if_expression(index)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else 0
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_item
@@ -446,7 +446,7 @@ class EditTableV2Executor(DeclarativeActionExecutor):
current_table[i] = evaluated_item
break
await state.set(table_path, current_table)
state.set(table_path, current_table)
await ctx.send_message(ActionComplete())
@@ -479,13 +479,13 @@ class ParseValueExecutor(DeclarativeActionExecutor):
if path and value is not None:
# Evaluate the value expression
evaluated_value = await state.eval_if_expression(value)
evaluated_value = state.eval_if_expression(value)
# Convert to target type if specified
if value_type:
evaluated_value = self._convert_to_type(evaluated_value, value_type)
await state.set(path, evaluated_value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
@@ -7,7 +7,7 @@ Control flow in the graph-based system is handled differently than the interpret
returns a ConditionResult with the first-matching branch index. Edge conditions
then check the branch_index to route to the correct branch. This ensures only
one branch executes (first-match semantics), matching the interpreter behavior.
- Foreach: Loop iteration state managed in SharedState + loop edges
- Foreach: Loop iteration state managed in State + loop edges
- Goto: Edge to target action (handled by builder)
- Break/Continue: Special signals for loop control
@@ -30,7 +30,7 @@ from ._declarative_base import (
LoopIterationResult,
)
# Keys for loop state in SharedState
# Keys for loop state in State
LOOP_STATE_KEY = "_declarative_loop_state"
# Index value indicating the else/default branch
@@ -88,7 +88,7 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
elif isinstance(condition_expr, str) and not condition_expr.startswith("="):
condition_expr = f"={condition_expr}"
result = await state.eval(condition_expr)
result = state.eval(condition_expr)
if bool(result):
# First matching condition found
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=result))
@@ -143,7 +143,7 @@ class SwitchEvaluatorExecutor(DeclarativeActionExecutor):
return
# Evaluate the switch value once
switch_value = await state.eval_if_expression(value_expr)
switch_value = state.eval_if_expression(value_expr)
# Compare against each case's match value
for index, case_item in enumerate(self._cases):
@@ -152,7 +152,7 @@ class SwitchEvaluatorExecutor(DeclarativeActionExecutor):
continue
# Evaluate the match value
match_value = await state.eval_if_expression(match_expr)
match_value = state.eval_if_expression(match_expr)
if switch_value == match_value:
# Found matching case
@@ -196,7 +196,7 @@ class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluate the condition and output the result."""
state = await self._ensure_state_initialized(ctx, trigger)
result = await state.eval(self._condition_expr)
result = state.eval(self._condition_expr)
is_truthy = bool(result)
if is_truthy:
@@ -208,7 +208,7 @@ class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
class ForeachInitExecutor(DeclarativeActionExecutor):
"""Initializes a foreach loop.
Sets up the loop state in SharedState and determines if there are items.
Sets up the loop state in State and determines if there are items.
"""
@handler
@@ -226,7 +226,7 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
items_expr = (
self._action_def.get("itemsSource") or self._action_def.get("items") or self._action_def.get("source")
)
items_raw: Any = await state.eval_if_expression(items_expr) or []
items_raw: Any = state.eval_if_expression(items_expr) or []
items: list[Any]
items = (list(items_raw) if items_raw else []) if not isinstance(items_raw, (list, tuple)) else list(items_raw) # type: ignore
@@ -234,14 +234,14 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
loop_id = self.id
# Store loop state
state_data = await state.get_state_data()
state_data = state.get_state_data()
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).setdefault(LOOP_STATE_KEY, {})
loop_states[loop_id] = {
"items": items,
"index": 0,
"length": len(items),
}
await state.set_state_data(state_data)
state.set_state_data(state_data)
# Check if we have items
if items:
@@ -263,9 +263,9 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
index_name = self._action_def.get("indexName", "index")
index_var = f"Local.{index_name}"
await state.set(item_var, items[0])
state.set(item_var, items[0])
if index_var:
await state.set(index_var, 0)
state.set(index_var, 0)
await ctx.send_message(LoopIterationResult(has_next=True, current_item=items[0], current_index=0))
else:
@@ -307,7 +307,7 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
loop_id = self._init_executor_id
# Get loop state
state_data = await state.get_state_data()
state_data = state.get_state_data()
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
loop_state = loop_states.get(loop_id)
@@ -322,7 +322,7 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
if current_index < len(items):
# Update loop state
loop_state["index"] = current_index
await state.set_state_data(state_data)
state.set_state_data(state_data)
# Set the iteration variable
# Support multiple schema formats:
@@ -342,9 +342,9 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
index_name = self._action_def.get("indexName", "index")
index_var = f"Local.{index_name}"
await state.set(item_var, items[current_index])
state.set(item_var, items[current_index])
if index_var:
await state.set(index_var, current_index)
state.set(index_var, current_index)
await ctx.send_message(
LoopIterationResult(has_next=True, current_item=items[current_index], current_index=current_index)
@@ -354,7 +354,7 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
loop_states_dict = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
if loop_id in loop_states_dict:
del loop_states_dict[loop_id]
await state.set_state_data(state_data)
state.set_state_data(state_data)
await ctx.send_message(LoopIterationResult(has_next=False))
@@ -365,15 +365,15 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
ctx: WorkflowContext[LoopIterationResult],
) -> None:
"""Handle break/continue signals."""
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
if control.action == "break":
# Clean up loop state and signal done
state_data = await state.get_state_data()
state_data = state.get_state_data()
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
if self._init_executor_id in loop_states:
del loop_states[self._init_executor_id]
await state.set_state_data(state_data)
state.set_state_data(state_data)
await ctx.send_message(LoopIterationResult(has_next=False))
@@ -84,7 +84,7 @@ class QuestionExecutor(DeclarativeActionExecutor):
allow_free_text = self._action_def.get("allowFreeText", True)
# Evaluate the question text if it's an expression
evaluated_question = await state.eval_if_expression(question_text)
evaluated_question = state.eval_if_expression(question_text)
# Build choices metadata
choices_data: list[dict[str, str]] | None = None
@@ -101,8 +101,8 @@ class QuestionExecutor(DeclarativeActionExecutor):
choices_data.append({"value": str(c), "label": str(c)})
# Store output property in shared state for response handler
await ctx.shared_state.set("_question_output_property", output_property)
await ctx.shared_state.set("_question_default_value", default_value)
ctx.state.set("_question_output_property", output_property)
ctx.state.set("_question_default_value", default_value)
# Request external input - workflow pauses here
await ctx.request_info(
@@ -128,13 +128,13 @@ class QuestionExecutor(DeclarativeActionExecutor):
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the user's response to the question."""
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.answer")
answer = response.value if response.value is not None else response.user_input
if output_property:
await state.set(output_property, answer)
state.set(output_property, answer)
await ctx.send_message(ActionComplete())
@@ -163,7 +163,7 @@ class ConfirmationExecutor(DeclarativeActionExecutor):
default_value = self._action_def.get("defaultValue", False)
# Evaluate the message if it's an expression
evaluated_message = await state.eval_if_expression(message)
evaluated_message = state.eval_if_expression(message)
# Request confirmation - workflow pauses here
await ctx.request_info(
@@ -189,7 +189,7 @@ class ConfirmationExecutor(DeclarativeActionExecutor):
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the user's confirmation response."""
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.confirmed")
@@ -202,7 +202,7 @@ class ConfirmationExecutor(DeclarativeActionExecutor):
confirmed = user_input_lower in ("yes", "y", "true", "1", "confirm", "ok")
if output_property:
await state.set(output_property, confirmed)
state.set(output_property, confirmed)
await ctx.send_message(ActionComplete())
@@ -231,7 +231,7 @@ class WaitForInputExecutor(DeclarativeActionExecutor):
# Emit prompt if specified
if prompt:
evaluated_prompt = await state.eval_if_expression(prompt)
evaluated_prompt = state.eval_if_expression(prompt)
await ctx.yield_output(str(evaluated_prompt))
# Request user input - workflow pauses here
@@ -256,12 +256,12 @@ class WaitForInputExecutor(DeclarativeActionExecutor):
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle the user's input."""
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.input")
if output_property:
await state.set(output_property, response.user_input)
state.set(output_property, response.user_input)
await ctx.send_message(ActionComplete())
@@ -292,7 +292,7 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
metadata = self._action_def.get("metadata", {})
# Evaluate the message if it's an expression
evaluated_message = await state.eval_if_expression(message)
evaluated_message = state.eval_if_expression(message)
# Build request metadata
request_metadata: dict[str, Any] = {
@@ -323,14 +323,14 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the external input response."""
state = self._get_state(ctx.shared_state)
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.externalInput")
# Store the response value or user_input
result = response.value if response.value is not None else response.user_input
if output_property:
await state.set(output_property, result)
state.set(output_property, result)
await ctx.send_message(ActionComplete())