From 2d5cd9f54e420eb7d2a39564c5b08be2848788c4 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 22 Jan 2026 09:52:08 +0900 Subject: [PATCH 1/6] add(declarative): Declarative workflow InvokeFunctionTool feature --- .../_workflows/__init__.py | 20 + .../_workflows/_declarative_builder.py | 22 +- .../_workflows/_executors_tools.py | 710 ++++++++++++++ .../_workflows/_factory.py | 60 +- .../declarative/tests/test_actions_agents.py | 885 ++++++++++++++++++ .../tests/test_declarative_loader.py | 420 +++++++++ .../tests/test_function_tool_executor.py | 717 ++++++++++++++ .../tests/test_powerfx_functions.py | 438 +++++++++ .../tests/test_workflow_factory.py | 641 +++++++++++++ .../tests/test_workflow_handlers.py | 691 ++++++++++++++ .../declarative/tests/test_workflow_state.py | 336 +++++++ .../getting_started/workflows/README.md | 2 + .../workflows/declarative/README.md | 3 + .../agent_to_function_tool/__init__.py | 3 + .../agent_to_function_tool/main.py | 262 ++++++ .../agent_to_function_tool/workflow.yaml | 56 ++ .../invoke_function_tool/__init__.py | 7 + .../declarative/invoke_function_tool/main.py | 116 +++ .../invoke_function_tool/workflow.yaml | 50 + 19 files changed, 5434 insertions(+), 5 deletions(-) create mode 100644 python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py create mode 100644 python/packages/declarative/tests/test_actions_agents.py create mode 100644 python/packages/declarative/tests/test_function_tool_executor.py create mode 100644 python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py create mode 100644 python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py create mode 100644 python/samples/getting_started/workflows/declarative/agent_to_function_tool/workflow.yaml create mode 100644 python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py create mode 100644 python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py create mode 100644 python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py b/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py index 9fb693b18b..aa4ccfd1f0 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py @@ -68,6 +68,17 @@ RequestExternalInputExecutor, WaitForInputExecutor, ) +from ._executors_tools import ( + FUNCTION_TOOL_REGISTRY_KEY, + TOOL_ACTION_EXECUTORS, + TOOL_APPROVAL_STATE_KEY, + BaseToolExecutor, + InvokeFunctionToolExecutor, + ToolApprovalRequest, + ToolApprovalResponse, + ToolApprovalState, + ToolInvocationResult, +) from ._factory import DeclarativeWorkflowError, WorkflowFactory from ._handlers import ActionHandler, action_handler, get_action_handler from ._human_input import ( @@ -86,6 +97,9 @@ "CONTROL_FLOW_EXECUTORS", "DECLARATIVE_STATE_KEY", "EXTERNAL_INPUT_EXECUTORS", + "FUNCTION_TOOL_REGISTRY_KEY", + "TOOL_ACTION_EXECUTORS", + "TOOL_APPROVAL_STATE_KEY", "TOOL_REGISTRY_KEY", "ActionComplete", "ActionHandler", @@ -95,6 +109,7 @@ "AgentInvocationError", "AgentResult", "AppendValueExecutor", + "BaseToolExecutor", "BreakLoopExecutor", "ClearAllVariablesExecutor", "ConfirmationExecutor", @@ -116,6 +131,7 @@ "ForeachInitExecutor", "ForeachNextExecutor", "InvokeAzureAgentExecutor", + "InvokeFunctionToolExecutor", "InvokeToolExecutor", "JoinExecutor", "LoopControl", @@ -129,6 +145,10 @@ "SetTextVariableExecutor", "SetValueExecutor", "SetVariableExecutor", + "ToolApprovalRequest", + "ToolApprovalResponse", + "ToolApprovalState", + "ToolInvocationResult", "WaitForInputExecutor", "WorkflowFactory", "WorkflowState", diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py index 84ecc8ea4e..008ecd830b 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py @@ -11,8 +11,11 @@ - Loop edges for foreach """ +import logging from typing import Any +logger = logging.getLogger(__name__) + from agent_framework._workflows import ( Workflow, WorkflowBuilder, @@ -36,6 +39,7 @@ SwitchEvaluatorExecutor, ) from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS +from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor # Combined mapping of all action kinds to executor classes ALL_ACTION_EXECUTORS = { @@ -43,6 +47,7 @@ **CONTROL_FLOW_EXECUTORS, **AGENT_ACTION_EXECUTORS, **EXTERNAL_INPUT_EXECUTORS, + **TOOL_ACTION_EXECUTORS, } # Action kinds that terminate control flow (no fall-through to successor) @@ -66,6 +71,7 @@ "RequestHumanInput": ["variable"], "WaitForHumanInput": ["variable"], "EmitEvent": ["event"], + "InvokeFunctionTool": ["functionName"], } # Alternate field names that satisfy required field requirements @@ -106,6 +112,7 @@ def __init__( yaml_definition: dict[str, Any], workflow_id: str | None = None, agents: dict[str, Any] | None = None, + tools: dict[str, Any] | None = None, checkpoint_storage: Any | None = None, validate: bool = True, ): @@ -115,6 +122,7 @@ def __init__( yaml_definition: The parsed YAML workflow definition workflow_id: Optional ID for the workflow (defaults to name from YAML) agents: Registry of agent instances by name (for InvokeAzureAgent actions) + tools: Registry of tool/function instances by name (for FunctionTool actions) checkpoint_storage: Optional checkpoint storage for pause/resume support validate: Whether to validate the workflow definition before building (default: True) """ @@ -123,6 +131,7 @@ def __init__( self._executors: dict[str, Any] = {} # id -> executor self._action_index = 0 # Counter for generating unique IDs self._agents = agents or {} # Agent registry for agent executors + self._tools = tools or {} # Tool registry for tool executors self._checkpoint_storage = checkpoint_storage self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id) self._validate = validate @@ -404,8 +413,13 @@ def _create_executor_for_action( executor_class = ALL_ACTION_EXECUTORS.get(kind) if executor_class is None: - # Unknown action type - skip with warning - # In production, might want to log this + # Unknown action type - log warning and skip + logger.warning( + "Unknown action kind '%s' encountered at index %d - action will be skipped. Available action kinds: %s", + kind, + self._action_index, + list(ALL_ACTION_EXECUTORS.keys()), + ) return None # Create the executor with ID @@ -418,10 +432,12 @@ def _create_executor_for_action( action_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}" self._action_index += 1 - # Pass agents to agent-related executors + # Pass agents/tools to specialized executors executor: Any if kind in ("InvokeAzureAgent",): executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents) + elif kind == "InvokeFunctionTool": + executor = InvokeFunctionToolExecutor(action_def, id=action_id, tools=self._tools) else: executor = executor_class(action_def, id=action_id) self._executors[action_id] = executor diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py new file mode 100644 index 0000000000..ec56f6df60 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py @@ -0,0 +1,710 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tool invocation executors for declarative workflows. + +Provides base abstractions and concrete executors for invoking various tool types +(functions, APIs, MCP servers, etc.) with support for approval flows and structured output. + +This module is designed for extensibility: +- BaseToolExecutor provides common patterns (registry lookup, approval flow, output formatting) +- Concrete executors (InvokeFunctionToolExecutor) implement tool-specific invocation logic +- New tool types can be added by subclassing BaseToolExecutor +""" + +import json +import logging +import uuid +from abc import abstractmethod +from dataclasses import dataclass, field +from inspect import isawaitable +from typing import Any + +from agent_framework import ( + ChatMessage, + Content, + WorkflowContext, + handler, + response_handler, +) + +from ._declarative_base import ( + ActionComplete, + DeclarativeActionExecutor, + DeclarativeWorkflowState, +) +from ._executors_agents import TOOL_REGISTRY_KEY + +logger = logging.getLogger(__name__) + +# Registry key for function tools in SharedState - reuse existing key for compatibility +FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY + +# State key for storing approval state during yield/resume +TOOL_APPROVAL_STATE_KEY = "_tool_approval_state" + + +# ============================================================================ +# Request/Response Types for Approval Flow +# ============================================================================ + + +@dataclass +class ToolApprovalRequest: + """Request for approval before invoking a tool. + + Emitted when requireApproval=true, signaling that the workflow should yield + and wait for user approval before invoking the tool. + + This follows the same pattern as AgentExternalInputRequest from _executors_agents.py, + allowing consistent handling of human-in-loop scenarios across agents and tools. + + Attributes: + request_id: Unique identifier for this approval request. + function_name: Evaluated function name to be invoked. + arguments: Evaluated arguments to be passed to the function. + conversation_id: Optional conversation ID for context. + """ + + request_id: str + function_name: str + arguments: dict[str, Any] + conversation_id: str | None = None + + +@dataclass +class ToolApprovalResponse: + """Response to a ToolApprovalRequest. + + Provided by the caller to approve or reject tool invocation. + + Attributes: + approved: Whether the tool invocation was approved. + reason: Optional reason for rejection. + """ + + approved: bool + reason: str | None = None + + +# ============================================================================ +# State Types for Approval Flow +# ============================================================================ + + +@dataclass +class ToolApprovalState: + """State saved during approval yield for resumption. + + Stored in SharedState under TOOL_APPROVAL_STATE_KEY when requireApproval=true. + Retrieved by handle_approval_response() to continue execution. + """ + + function_name: str + arguments: dict[str, Any] + output_messages_var: str | None + output_result_var: str | None + conversation_id: str | None + + +# ============================================================================ +# Result Types +# ============================================================================ + + +@dataclass +class ToolInvocationResult: + """Result from a tool invocation. + + Attributes: + success: Whether the invocation succeeded. + result: The return value from the tool (if successful). + error: Error message (if failed). + messages: ChatMessage list format for conversation history. + rejected: Whether the invocation was rejected during approval. + rejection_reason: Reason for rejection. + """ + + success: bool + result: Any = None + error: str | None = None + messages: list[ChatMessage] = field(default_factory=list) + rejected: bool = False + rejection_reason: str | None = None + + +# ============================================================================ +# Helper Functions +# ============================================================================ + + +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 'weatherResult' + + Returns: + The variable path with a scope prefix (defaults to Local if none provided) + """ + if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")): + return variable + if "." in variable: + return variable + return "Local." + variable + + +# ============================================================================ +# Base Tool Executor (Abstract) +# ============================================================================ + + +class BaseToolExecutor(DeclarativeActionExecutor): + """Base class for tool invocation executors. + + Provides common functionality for all tool-like executors: + - Tool registry lookup (SharedState + WorkflowFactory registration) + - Approval flow (request_info pattern with yield/resume) + - Output formatting (messages as ChatMessage list + result variable) + - Error handling (stores error in output, doesn't raise) + + Subclasses must implement: + - _invoke_tool(): Perform the actual tool invocation + + YAML Schema (common fields): + kind: + id: unique_id + conversationId: =System.ConversationId # optional + functionName: function_to_call # required, supports =expression syntax + requireApproval: true # optional, default=false + arguments: # optional dictionary + param1: value1 + param2: =Local.dynamicValue + output: + messages: Local.toolCallMessages # ChatMessage list + result: Local.toolResult + """ + + def __init__( + self, + action_def: dict[str, Any], + *, + id: str | None = None, + tools: dict[str, Any] | None = None, + ): + """Initialize the tool executor. + + Args: + action_def: The action definition from YAML + id: Optional executor ID + tools: Registry of tool instances by name (from WorkflowFactory) + """ + super().__init__(action_def, id=id) + self._tools = tools or {} + + @abstractmethod + async def _invoke_tool( + self, + tool: Any, + function_name: str, + arguments: dict[str, Any], + state: DeclarativeWorkflowState, + ) -> Any: + """Invoke the tool with the given arguments. + + Args: + tool: The tool instance to invoke + function_name: Function/method name to call + arguments: Arguments to pass + state: Workflow state + + Returns: + The result from the tool invocation + + Raises: + Any exception from the tool invocation + """ + pass + + async def _get_tool( + self, + function_name: str, + ctx: WorkflowContext[Any, Any], + ) -> Any | None: + """Get tool from registry. + + Checks both WorkflowFactory registry (self._tools) and SharedState registry. + + Args: + function_name: Name of the function + ctx: Workflow context + + Returns: + The tool/function, or None if not found + """ + # Check WorkflowFactory registry first (passed in constructor) + tool = self._tools.get(function_name) + if tool is not None: + return tool + + # Check SharedState registry (for runtime registration) + try: + tool_registry: dict[str, Any] | None = await ctx.shared_state.get(FUNCTION_TOOL_REGISTRY_KEY) + if tool_registry: + return tool_registry.get(function_name) + except KeyError: + logger.debug( + "%s: tool registry key '%s' not found in shared state " + "(this is normal if tools are only registered via WorkflowFactory)", + self.__class__.__name__, + FUNCTION_TOOL_REGISTRY_KEY, + ) + + return None + + def _get_output_config(self) -> tuple[str | None, str | None]: + """Parse output configuration from action definition. + + Returns: + Tuple of (messages_var, result_var) + """ + output_config = self._action_def.get("output", {}) + + if not isinstance(output_config, dict): + return None, None + + messages_var = output_config.get("messages") + result_var = output_config.get("result") + + return ( + str(messages_var) if messages_var else None, + str(result_var) if result_var else None, + ) + + async def _store_result( + self, + result: ToolInvocationResult, + state: DeclarativeWorkflowState, + messages_var: str | None, + result_var: str | None, + ) -> None: + """Store tool invocation result in workflow state. + + Args: + result: The tool invocation result + state: Workflow state + messages_var: Variable path for messages output + result_var: Variable path for result output + """ + # Store messages if variable specified + if messages_var: + path = _normalize_variable_path(messages_var) + await state.set(path, result.messages) + + # Store result if variable specified + if result_var: + path = _normalize_variable_path(result_var) + if result.rejected: + await state.set( + path, + { + "approved": False, + "rejected": True, + "reason": result.rejection_reason, + }, + ) + elif result.success: + await state.set(path, result.result) + else: + await state.set( + path, + { + "error": result.error, + }, + ) + + async def _format_messages( + self, + function_name: str, + arguments: dict[str, Any], + result: Any, + ) -> list[ChatMessage]: + """Format tool invocation as ChatMessage list. + + Creates tool call + tool result message pair for conversation history, + following the same format as agent tool calls. + + Args: + function_name: Function name invoked + arguments: Arguments passed + result: Result from invocation + + Returns: + List of ChatMessage objects [tool_call_message, tool_result_message] + """ + call_id = str(uuid.uuid4()) + + # Safely serialize arguments to JSON + try: + arguments_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments) + except (TypeError, ValueError) as e: + logger.warning(f"Failed to serialize arguments to JSON: {e}") + arguments_str = str(arguments) + + # Tool call message (from assistant) + tool_call_content = Content.from_function_call( + call_id=call_id, + name=function_name, + arguments=arguments_str, + ) + tool_call_message = ChatMessage( + role="assistant", + contents=[tool_call_content], + ) + + # Safely serialize result to JSON + try: + result_str = json.dumps(result) if not isinstance(result, str) else result + except (TypeError, ValueError) as e: + logger.warning(f"Failed to serialize result to JSON: {e}") + result_str = str(result) + + tool_result_content = Content.from_function_result( + call_id=call_id, + result=result_str, + ) + tool_result_message = ChatMessage( + role="tool", + contents=[tool_result_content], + ) + + return [tool_call_message, tool_result_message] + + async def _execute_tool_invocation( + self, + function_name: str, + arguments: dict[str, Any], + state: DeclarativeWorkflowState, + ctx: WorkflowContext[Any, Any], + ) -> ToolInvocationResult: + """Execute the tool invocation. + + Args: + function_name: Function to invoke + arguments: Arguments to pass + state: Workflow state + ctx: Workflow context + + Returns: + ToolInvocationResult with outcome + """ + # Get tool from registry + tool = await self._get_tool(function_name, ctx) + if tool is None: + error_msg = f"Function '{function_name}' not found in registry" + logger.error(f"{self.__class__.__name__}: {error_msg}") + return ToolInvocationResult( + success=False, + error=error_msg, + ) + + try: + # Invoke the tool (subclass implements this) + result_value = await self._invoke_tool( + tool=tool, + function_name=function_name, + arguments=arguments, + state=state, + ) + + # Format as messages for conversation history + messages = await self._format_messages( + function_name=function_name, + arguments=arguments, + result=result_value, + ) + + return ToolInvocationResult( + success=True, + result=result_value, + messages=messages, + ) + + except Exception as e: + logger.error( + "%s: error invoking function '%s': %s: %s", + self.__class__.__name__, + function_name, + type(e).__name__, + e, + exc_info=True, + ) + return ToolInvocationResult( + success=False, + error=f"{type(e).__name__}: {e}", + ) + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, str], + ) -> None: + """Handle the tool invocation with optional approval flow. + + When requireApproval=true: + 1. Saves invocation state to SharedState + 2. Emits ToolApprovalRequest via ctx.request_info() + 3. Workflow yields (returns without ActionComplete) + 4. Resumes in handle_approval_response() when user responds + """ + state = await self._ensure_state_initialized(ctx, trigger) + + # Parse output configuration early so we can store errors + messages_var, result_var = self._get_output_config() + + # Get and evaluate function name (required) + function_name_expr = self._action_def.get("functionName") + if not function_name_expr: + error_msg = f"Action '{self.id}' is missing required 'functionName' field" + logger.error(f"{self.__class__.__name__}: {error_msg}") + if result_var: + await state.set(_normalize_variable_path(result_var), {"error": error_msg}) + await ctx.send_message(ActionComplete()) + return + + function_name = await state.eval_if_expression(function_name_expr) + if not function_name: + error_msg = f"Action '{self.id}': functionName expression evaluated to empty" + logger.error(f"{self.__class__.__name__}: {error_msg}") + if result_var: + await state.set(_normalize_variable_path(result_var), {"error": error_msg}) + await ctx.send_message(ActionComplete()) + return + function_name = str(function_name) + + # Evaluate arguments + arguments_def = self._action_def.get("arguments", {}) + arguments: dict[str, Any] = {} + if arguments_def is not None and not isinstance(arguments_def, dict): + logger.warning( + "%s: 'arguments' must be a dictionary, got %s - ignoring", + self.__class__.__name__, + type(arguments_def).__name__, + ) + elif isinstance(arguments_def, dict): + for key, value in arguments_def.items(): + arguments[key] = await state.eval_if_expression(value) + + # Get conversation ID if specified + conversation_id_expr = self._action_def.get("conversationId") + conversation_id = None + if conversation_id_expr: + evaluated_id = await state.eval_if_expression(conversation_id_expr) + conversation_id = str(evaluated_id) if evaluated_id else None + + # Check if approval is required + require_approval = self._action_def.get("requireApproval", False) + + if require_approval: + # Save state for resumption + approval_state = ToolApprovalState( + function_name=function_name, + arguments=arguments, + output_messages_var=messages_var, + output_result_var=result_var, + conversation_id=conversation_id, + ) + await ctx.shared_state.set(TOOL_APPROVAL_STATE_KEY, approval_state) + + # Emit approval request - workflow yields here + request = ToolApprovalRequest( + request_id=str(uuid.uuid4()), + function_name=function_name, + arguments=arguments, + conversation_id=conversation_id, + ) + logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'") + await ctx.request_info(request, ToolApprovalResponse) + # Workflow yields - will resume in handle_approval_response + return + + # No approval required - invoke directly + result = await self._execute_tool_invocation( + function_name=function_name, + arguments=arguments, + state=state, + ctx=ctx, + ) + + await self._store_result(result, state, messages_var, result_var) + await ctx.send_message(ActionComplete()) + + @response_handler + async def handle_approval_response( + self, + original_request: ToolApprovalRequest, + response: ToolApprovalResponse, + ctx: WorkflowContext[ActionComplete, str], + ) -> None: + """Handle response to a ToolApprovalRequest. + + Called when the workflow resumes after yielding for approval. + Either executes the tool (if approved) or stores rejection status. + """ + state = self._get_state(ctx.shared_state) + + # Retrieve saved invocation state + try: + approval_state: ToolApprovalState = await ctx.shared_state.get(TOOL_APPROVAL_STATE_KEY) + except KeyError: + error_msg = "Approval state not found, cannot resume tool invocation" + logger.error(f"{self.__class__.__name__}: {error_msg}") + # Try to store error - get output config from action def as fallback + _, result_var = self._get_output_config() + if result_var and state: + await state.set(_normalize_variable_path(result_var), {"error": error_msg}) + await ctx.send_message(ActionComplete()) + return + + # Clean up approval state + try: + await ctx.shared_state.delete(TOOL_APPROVAL_STATE_KEY) + except KeyError: + logger.warning(f"{self.__class__.__name__}: approval state already deleted") + + function_name = approval_state.function_name + arguments = approval_state.arguments + messages_var = approval_state.output_messages_var + result_var = approval_state.output_result_var + + # Check if approved + if not response.approved: + logger.info(f"{self.__class__.__name__}: tool invocation rejected: {response.reason}") + + # Store rejection status (don't raise error) + result = ToolInvocationResult( + success=False, + rejected=True, + rejection_reason=response.reason, + messages=[ + ChatMessage( + role="assistant", + text=f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}", + ) + ], + ) + await self._store_result(result, state, messages_var, result_var) + await ctx.send_message(ActionComplete()) + return + + # Approved - execute the invocation + result = await self._execute_tool_invocation( + function_name=function_name, + arguments=arguments, + state=state, + ctx=ctx, + ) + + await self._store_result(result, state, messages_var, result_var) + await ctx.send_message(ActionComplete()) + + +# ============================================================================ +# Function Tool Executor (Concrete) +# ============================================================================ + + +class InvokeFunctionToolExecutor(BaseToolExecutor): + """Executor that invokes a Python function as a tool. + + This executor supports invoking registered Python functions with: + - Expression evaluation for functionName and arguments + - Optional approval flow (yield/resume pattern) + - Async function support + - ChatMessage list output for conversation history + + YAML Schema: + kind: InvokeFunctionTool + id: invoke_function_example + conversationId: =System.ConversationId # optional + functionName: get_weather # required, supports =expression syntax + requireApproval: true # optional, default=false + arguments: # optional dictionary + location: =Local.location + unit: F + output: + messages: Local.weatherToolCallItems # ChatMessage list + result: Local.WeatherInfo + + Tool Registration: + Tools can be registered via: + 1. WorkflowFactory.register_tool("name", func) - preferred + 2. Setting FUNCTION_TOOL_REGISTRY_KEY in SharedState at runtime + + Examples: + .. code-block:: python + + from agent_framework_declarative import WorkflowFactory + + + def get_weather(location: str, unit: str = "F") -> dict: + return {"temp": 72, "unit": unit, "location": location} + + + async def fetch_data(url: str) -> dict: + # async function example + return {"data": "..."} + + + factory = ( + WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data) + ) + + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + """ + + async def _invoke_tool( + self, + tool: Any, + function_name: str, + arguments: dict[str, Any], + state: DeclarativeWorkflowState, + ) -> Any: + """Invoke the function tool. + + Supports: + - Direct callable functions + - Async functions (via inspect.isawaitable) + + Args: + tool: The tool/function to invoke + function_name: Name of the function (for error messages) + arguments: Arguments to pass to the function + state: Workflow state (not used for function tools) + + Returns: + The result from the function invocation + + Raises: + ValueError: If the tool is not callable + """ + if not callable(tool): + raise ValueError(f"Function '{function_name}' is not callable") + + # Invoke the function + result = tool(**arguments) + + # Handle async functions + if isawaitable(result): + result = await result + + return result + + +# ============================================================================ +# Executor Registry Export +# ============================================================================ + +TOOL_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = { + "InvokeFunctionTool": InvokeFunctionToolExecutor, +} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index 1e8dab9f30..b9dc63a837 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -134,6 +134,7 @@ def __init__( self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file) self._agents: dict[str, AgentProtocol | AgentExecutor] = dict(agents) if agents else {} self._bindings: dict[str, Any] = dict(bindings) if bindings else {} + self._tools: dict[str, Any] = {} # Tool registry for FunctionTool actions self._checkpoint_storage = checkpoint_storage def create_workflow_from_yaml_path( @@ -369,21 +370,23 @@ def _create_workflow( if description: normalized_def["description"] = description - # Build the graph-based workflow, passing agents for InvokeAzureAgent executors + # Build the graph-based workflow, passing agents and tools for specialized executors try: graph_builder = DeclarativeWorkflowBuilder( normalized_def, workflow_id=name, agents=agents, + tools=self._tools, checkpoint_storage=self._checkpoint_storage, ) workflow = graph_builder.build() except ValueError as e: raise DeclarativeWorkflowError(f"Failed to build graph-based workflow: {e}") from e - # Store agents and bindings for reference (executors already have them) + # Store agents, bindings, and tools for reference (executors already have them) workflow._declarative_agents = agents # type: ignore[attr-defined] workflow._declarative_bindings = self._bindings # type: ignore[attr-defined] + workflow._declarative_tools = self._tools # type: ignore[attr-defined] # Store input schema if defined in workflow definition # This allows DevUI to generate proper input forms @@ -592,6 +595,59 @@ def send_email(to: str, subject: str, body: str) -> bool: self._bindings[name] = func return self + def register_tool(self, name: str, func: Any) -> "WorkflowFactory": + """Register a tool function with the factory for use in FunctionTool actions. + + Registered tools are available to FunctionTool actions by name via the functionName field. + This method supports fluent chaining. + + Args: + name: The name to register the function under. Must match the functionName + referenced in FunctionTool actions. + func: The function to register (can be sync or async). + + Returns: + Self for method chaining. + + Examples: + .. code-block:: python + + from agent_framework_declarative import WorkflowFactory + + + def get_weather(location: str, unit: str = "F") -> dict: + return {"temp": 72, "unit": unit, "location": location} + + + async def fetch_data(url: str) -> dict: + # Async function example + return {"data": "..."} + + + # Register functions for use in FunctionTool workflow actions + factory = ( + WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data) + ) + + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + + The workflow YAML can then reference these tools: + + .. code-block:: yaml + + actions: + - kind: FunctionTool + id: call_weather + functionName: get_weather + arguments: + location: =Local.city + unit: F + output: + result: Local.weatherData + """ + self._tools[name] = func + return self + def _convert_inputs_to_json_schema(self, inputs_def: dict[str, Any]) -> dict[str, Any]: """Convert a declarative inputs definition to JSON Schema. diff --git a/python/packages/declarative/tests/test_actions_agents.py b/python/packages/declarative/tests/test_actions_agents.py new file mode 100644 index 0000000000..b3b13f3126 --- /dev/null +++ b/python/packages/declarative/tests/test_actions_agents.py @@ -0,0 +1,885 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for _actions_agents.py module. + +These tests cover: +- JSON extraction utility function +- Message building from state +- Agent invocation handlers +""" + +import json +from collections.abc import AsyncGenerator +from typing import Any + +import pytest + +from agent_framework_declarative._workflows._actions_agents import ( + _extract_json_from_response, +) +from agent_framework_declarative._workflows._handlers import ( + ActionContext, + WorkflowEvent, + get_action_handler, +) +from agent_framework_declarative._workflows._state import WorkflowState + + +def create_action_context( + action: dict[str, Any], + inputs: dict[str, Any] | None = None, + agents: dict[str, Any] | None = None, + bindings: dict[str, Any] | None = None, + state: WorkflowState | None = None, +) -> ActionContext: + """Helper to create an ActionContext for testing.""" + if state is None: + state = WorkflowState(inputs=inputs or {}) + + async def execute_actions( + actions: list[dict[str, Any]], state: WorkflowState + ) -> AsyncGenerator[WorkflowEvent, None]: + """Mock execute_actions that runs handlers for nested actions.""" + for nested_action in actions: + action_kind = nested_action.get("kind") + handler = get_action_handler(action_kind) + if handler: + ctx = ActionContext( + state=state, + action=nested_action, + execute_actions=execute_actions, + agents=agents or {}, + bindings=bindings or {}, + ) + async for event in handler(ctx): + yield event + + return ActionContext( + state=state, + action=action, + execute_actions=execute_actions, + agents=agents or {}, + bindings=bindings or {}, + ) + + +class TestExtractJsonFromResponse: + """Tests for _extract_json_from_response utility function.""" + + def test_pure_json_object(self): + """Test parsing pure JSON object.""" + text = '{"key": "value", "number": 42}' + result = _extract_json_from_response(text) + assert result == {"key": "value", "number": 42} + + def test_pure_json_array(self): + """Test parsing pure JSON array.""" + text = '[1, 2, 3, "four"]' + result = _extract_json_from_response(text) + assert result == [1, 2, 3, "four"] + + def test_json_in_markdown_code_block(self): + """Test extracting JSON from markdown code block with json tag.""" + text = """Here's the response: +```json +{"status": "success", "data": [1, 2, 3]} +``` +That's all!""" + result = _extract_json_from_response(text) + assert result == {"status": "success", "data": [1, 2, 3]} + + def test_json_in_plain_code_block(self): + """Test extracting JSON from plain markdown code block.""" + text = """Result: +``` +{"name": "test"} +```""" + result = _extract_json_from_response(text) + assert result == {"name": "test"} + + def test_json_with_leading_text(self): + """Test extracting JSON with leading explanatory text.""" + text = 'Here is the result: {"answer": 42}' + result = _extract_json_from_response(text) + assert result == {"answer": 42} + + def test_json_with_trailing_text(self): + """Test extracting JSON with trailing text.""" + text = '{"answer": 42} - that is the answer' + result = _extract_json_from_response(text) + assert result == {"answer": 42} + + def test_multiple_json_objects_returns_last(self): + """Test that multiple JSON objects returns the last valid one.""" + text = '{"partial": true} {"complete": true, "final": "result"}' + result = _extract_json_from_response(text) + assert result == {"complete": True, "final": "result"} + + def test_nested_json_object(self): + """Test parsing nested JSON objects.""" + text = '{"outer": {"inner": {"deep": "value"}}}' + result = _extract_json_from_response(text) + assert result == {"outer": {"inner": {"deep": "value"}}} + + def test_json_with_escaped_quotes(self): + """Test JSON with escaped quotes in strings.""" + text = '{"message": "He said \\"hello\\""}' + result = _extract_json_from_response(text) + assert result == {"message": 'He said "hello"'} + + def test_json_with_newlines_in_strings(self): + """Test JSON with newlines in string values.""" + text = '{"text": "line1\\nline2"}' + result = _extract_json_from_response(text) + assert result == {"text": "line1\nline2"} + + def test_empty_string_returns_none(self): + """Test that empty string returns None.""" + result = _extract_json_from_response("") + assert result is None + + def test_whitespace_only_returns_none(self): + """Test that whitespace-only string returns None.""" + result = _extract_json_from_response(" \n\t ") + assert result is None + + def test_none_input_returns_none(self): + """Test that None-like empty input returns None.""" + result = _extract_json_from_response("") + assert result is None + + def test_no_json_raises_error(self): + """Test that text with no JSON raises JSONDecodeError.""" + text = "This is just plain text with no JSON" + with pytest.raises(json.JSONDecodeError): + _extract_json_from_response(text) + + def test_malformed_json_raises_error(self): + """Test that malformed JSON raises JSONDecodeError.""" + text = '{"key": "value", missing_quote: bad}' + with pytest.raises(json.JSONDecodeError): + _extract_json_from_response(text) + + def test_json_array_in_text(self): + """Test extracting JSON array from surrounding text.""" + text = "The numbers are: [1, 2, 3, 4, 5] in order" + result = _extract_json_from_response(text) + assert result == [1, 2, 3, 4, 5] + + def test_complex_nested_structure(self): + """Test complex nested JSON structure.""" + text = """ + ```json + { + "users": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ], + "metadata": { + "count": 2, + "page": 1 + } + } + ``` + """ + result = _extract_json_from_response(text) + assert result == { + "users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], + "metadata": {"count": 2, "page": 1}, + } + + def test_json_with_boolean_and_null(self): + """Test JSON with boolean and null values.""" + text = '{"active": true, "deleted": false, "data": null}' + result = _extract_json_from_response(text) + assert result == {"active": True, "deleted": False, "data": None} + + def test_multiple_code_blocks_returns_last(self): + """Test that multiple code blocks returns last valid JSON.""" + text = """ +First attempt: +```json +{"attempt": 1} +``` + +Final result: +```json +{"attempt": 2, "final": true} +``` +""" + result = _extract_json_from_response(text) + assert result == {"attempt": 2, "final": True} + + def test_json_with_unicode(self): + """Test JSON with unicode characters.""" + text = '{"greeting": "Hello, δΈ–η•Œ!", "emoji": "πŸ‘‹"}' + result = _extract_json_from_response(text) + assert result == {"greeting": "Hello, δΈ–η•Œ!", "emoji": "πŸ‘‹"} + + def test_json_with_numbers(self): + """Test JSON with various number formats.""" + text = '{"int": 42, "float": 3.14, "negative": -10, "scientific": 1.5e10}' + result = _extract_json_from_response(text) + assert result == {"int": 42, "float": 3.14, "negative": -10, "scientific": 1.5e10} + + def test_empty_json_object(self): + """Test empty JSON object.""" + text = "{}" + result = _extract_json_from_response(text) + assert result == {} + + def test_empty_json_array(self): + """Test empty JSON array.""" + text = "[]" + result = _extract_json_from_response(text) + assert result == [] + + def test_json_with_backslashes(self): + """Test JSON with backslash escapes.""" + text = '{"path": "C:\\\\Users\\\\test"}' + result = _extract_json_from_response(text) + assert result == {"path": "C:\\Users\\test"} + + +class TestNormalizeVariablePath: + """Tests for _normalize_variable_path utility function.""" + + def test_local_prefix_unchanged(self): + """Test that Local. prefix is preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("Local.myVar") + assert result == "Local.myVar" + + def test_system_prefix_unchanged(self): + """Test that System. prefix is preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("System.ConversationId") + assert result == "System.ConversationId" + + def test_workflow_prefix_unchanged(self): + """Test that Workflow. prefix is preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("Workflow.step") + assert result == "Workflow.step" + + def test_agent_prefix_unchanged(self): + """Test that Agent. prefix is preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("Agent.result") + assert result == "Agent.result" + + def test_conversation_prefix_unchanged(self): + """Test that Conversation. prefix is preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("Conversation.messages") + assert result == "Conversation.messages" + + def test_custom_namespace_preserved(self): + """Test that custom namespaces with dots are preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("Custom.myVar") + assert result == "Custom.myVar" + + def test_no_prefix_defaults_to_local(self): + """Test that variables without prefix default to Local.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("myVariable") + assert result == "Local.myVariable" + + def test_nested_path_preserved(self): + """Test that nested paths are preserved.""" + from agent_framework_declarative._workflows._actions_agents import _normalize_variable_path + + result = _normalize_variable_path("Local.data.nested.value") + assert result == "Local.data.nested.value" + + +class TestBuildMessagesFromState: + """Tests for _build_messages_from_state function.""" + + def test_empty_conversation_returns_empty_list(self): + """Test that empty conversation returns empty message list.""" + from agent_framework_declarative._workflows._actions_agents import _build_messages_from_state + + ctx = create_action_context(action={"kind": "InvokeAzureAgent"}) + messages = _build_messages_from_state(ctx) + assert messages == [] + + def test_conversation_messages_included(self): + """Test that conversation messages are included in result.""" + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import _build_messages_from_state + + state = WorkflowState() + msg1 = ChatMessage(role="user", text="Hello") + msg2 = ChatMessage(role="assistant", text="Hi there!") + state.set("conversation.messages", [msg1, msg2]) + + ctx = create_action_context(action={"kind": "InvokeAzureAgent"}, state=state) + messages = _build_messages_from_state(ctx) + assert len(messages) == 2 + assert messages[0].text == "Hello" + assert messages[1].text == "Hi there!" + + def test_none_conversation_returns_empty(self): + """Test that None conversation returns empty list.""" + from agent_framework_declarative._workflows._actions_agents import _build_messages_from_state + + state = WorkflowState() + state.set("conversation.messages", None) + + ctx = create_action_context(action={"kind": "InvokeAzureAgent"}, state=state) + messages = _build_messages_from_state(ctx) + assert messages == [] + + +class TestInvokeAzureAgentHandler: + """Tests for handle_invoke_azure_agent action handler.""" + + @pytest.mark.asyncio + async def test_missing_agent_name_logs_warning(self): + """Test that missing agent name logs warning and returns.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + ctx = create_action_context(action={"kind": "InvokeAzureAgent"}) + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_name_from_string(self): + """Test that agent name can be provided as string.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": "myAgent"}, + agents={}, # Agent not found, so will return early + ) + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_name_from_dict(self): + """Test that agent name can be provided as dict with name key.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": {"name": "myAgent"}}, + ) + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_name_from_expression(self): + """Test that agent name can be evaluated from expression.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + state = WorkflowState() + state.set("Local.agentName", "dynamicAgent") + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": {"name": "=Local.agentName"}}, + state=state, + ) + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_not_found_logs_error(self): + """Test that agent not found logs error and returns.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": "nonExistentAgent"}, + ) + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_streaming_agent_with_run_stream(self): + """Test invocation of streaming agent with run_stream method.""" + from typing import Any + from unittest.mock import MagicMock + + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + from agent_framework_declarative._workflows._handlers import ( + AgentResponseEvent, + AgentStreamingChunkEvent, + ) + + # Create a mock streaming agent + mock_agent = MagicMock() + mock_chunk1 = MagicMock() + mock_chunk1.text = "Hello" + mock_chunk1.tool_calls = [] + mock_chunk2 = MagicMock() + mock_chunk2.text = " World" + mock_chunk2.tool_calls = [] + + async def mock_run_stream(messages: list[Any]): + yield mock_chunk1 + yield mock_chunk2 + + mock_agent.run_stream = mock_run_stream + + state = WorkflowState() + state.set("conversation.messages", [ChatMessage(role="user", text="Test")]) + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": "testAgent", "outputPath": "Local.result"}, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + + # Should have streaming chunks and final response + streaming_chunks = [e for e in events if isinstance(e, AgentStreamingChunkEvent)] + response_events = [e for e in events if isinstance(e, AgentResponseEvent)] + + assert len(streaming_chunks) == 2 + assert streaming_chunks[0].chunk == "Hello" + assert streaming_chunks[1].chunk == " World" + assert len(response_events) == 1 + + @pytest.mark.asyncio + async def test_non_streaming_agent_with_run(self): + """Test invocation of non-streaming agent with run method.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + from agent_framework_declarative._workflows._handlers import AgentResponseEvent + + # Create a mock non-streaming agent (with spec to exclude run_stream) + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = "Response text" + mock_response.messages = [ChatMessage(role="assistant", text="Response text")] + mock_response.tool_calls = None + mock_agent.run = AsyncMock(return_value=mock_response) + + state = WorkflowState() + state.set("conversation.messages", [ChatMessage(role="user", text="Test")]) + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": "testAgent", "outputPath": "Local.result"}, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], AgentResponseEvent) + assert events[0].text == "Response text" + assert state.get("Local.result") == "Response text" + + @pytest.mark.asyncio + async def test_input_messages_from_string(self): + """Test that input messages from string are handled.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = "Response" + mock_response.messages = [] + mock_response.tool_calls = None + mock_agent.run = AsyncMock(return_value=mock_response) + + ctx = create_action_context( + action={ + "kind": "InvokeAzureAgent", + "agent": "testAgent", + "input": {"messages": "User input string"}, + }, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert len(events) == 1 + + # Verify the agent was called with messages including the input + call_args = mock_agent.run.call_args[0][0] + assert any(msg.text == "User input string" for msg in call_args) + + @pytest.mark.asyncio + async def test_input_messages_from_list(self): + """Test that input messages from list are handled.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + mock_agent = MagicMock() + mock_response = MagicMock() + mock_response.text = "Response" + mock_response.messages = [] + mock_response.tool_calls = None + mock_agent.run = AsyncMock(return_value=mock_response) + + ctx = create_action_context( + action={ + "kind": "InvokeAzureAgent", + "agent": "testAgent", + "input": { + "messages": [ + "String message", + {"role": "user", "content": "Dict message"}, + {"role": "assistant", "content": "Assistant message"}, + {"role": "system", "content": "System message"}, + ] + }, + }, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert len(events) == 1 + + @pytest.mark.asyncio + async def test_output_response_object_json_parsing(self): + """Test that responseObject output parses JSON from response.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = '{"result": "parsed", "count": 42}' + mock_response.messages = [] + mock_response.tool_calls = None + mock_agent.run = AsyncMock(return_value=mock_response) + + state = WorkflowState() + ctx = create_action_context( + action={ + "kind": "InvokeAzureAgent", + "agent": "testAgent", + "output": {"responseObject": "Local.parsed"}, + }, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert len(events) == 1 + + # Verify the parsed JSON was stored + parsed = state.get("Local.parsed") + assert parsed == {"result": "parsed", "count": 42} + + @pytest.mark.asyncio + async def test_output_messages_storage(self): + """Test that output messages are stored in specified variable.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = "Response" + mock_response.messages = [ChatMessage(role="assistant", text="Response")] + mock_response.tool_calls = None + mock_agent.run = AsyncMock(return_value=mock_response) + + state = WorkflowState() + ctx = create_action_context( + action={ + "kind": "InvokeAzureAgent", + "agent": "testAgent", + "output": {"messages": "Local.outputMessages"}, + }, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert len(events) == 1 + + # Verify messages were stored + stored = state.get("Local.outputMessages") + assert len(stored) == 1 + + @pytest.mark.asyncio + async def test_agent_without_run_methods(self): + """Test agent without run or run_stream methods logs error.""" + from unittest.mock import MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + mock_agent = MagicMock(spec=[]) # No run or run_stream methods + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": "testAgent"}, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_azure_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_error_raises_exception(self): + """Test that agent errors are propagated.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_azure_agent + + mock_agent = MagicMock(spec=["run"]) + mock_agent.run = AsyncMock(side_effect=RuntimeError("Agent failed")) + + ctx = create_action_context( + action={"kind": "InvokeAzureAgent", "agent": "testAgent"}, + agents={"testAgent": mock_agent}, + ) + + with pytest.raises(RuntimeError, match="Agent failed"): + [event async for event in handle_invoke_azure_agent(ctx)] + + +class TestInvokePromptAgentHandler: + """Tests for handle_invoke_prompt_agent action handler.""" + + @pytest.mark.asyncio + async def test_missing_agent_property_logs_warning(self): + """Test that missing agent property logs warning.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + ctx = create_action_context(action={"kind": "InvokePromptAgent"}) + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_non_string_agent_property_logs_warning(self): + """Test that non-string agent property logs warning.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + ctx = create_action_context( + action={"kind": "InvokePromptAgent", "agent": {"name": "notAString"}}, + ) + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_not_found_logs_error(self): + """Test that agent not found logs error.""" + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + ctx = create_action_context( + action={"kind": "InvokePromptAgent", "agent": "missingAgent"}, + ) + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_streaming_agent(self): + """Test invocation of streaming prompt agent.""" + from typing import Any + from unittest.mock import MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + from agent_framework_declarative._workflows._handlers import ( + AgentResponseEvent, + AgentStreamingChunkEvent, + ) + + mock_agent = MagicMock() + mock_chunk = MagicMock() + mock_chunk.text = "Chunk" + + async def mock_run_stream(messages: list[Any]): + yield mock_chunk + + mock_agent.run_stream = mock_run_stream + + ctx = create_action_context( + action={"kind": "InvokePromptAgent", "agent": "testAgent", "outputPath": "Local.result"}, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_prompt_agent(ctx)] + + streaming_chunks = [e for e in events if isinstance(e, AgentStreamingChunkEvent)] + response_events = [e for e in events if isinstance(e, AgentResponseEvent)] + + assert len(streaming_chunks) == 1 + assert len(response_events) == 1 + + @pytest.mark.asyncio + async def test_non_streaming_agent(self): + """Test invocation of non-streaming prompt agent.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + from agent_framework_declarative._workflows._handlers import AgentResponseEvent + + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = "Response text" + mock_response.messages = [ChatMessage(role="assistant", text="Response text")] + mock_agent.run = AsyncMock(return_value=mock_response) + + state = WorkflowState() + ctx = create_action_context( + action={ + "kind": "InvokePromptAgent", + "agent": "testAgent", + "outputPath": "Local.result", + }, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_prompt_agent(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], AgentResponseEvent) + assert events[0].text == "Response text" + assert state.get("Local.result") == "Response text" + + @pytest.mark.asyncio + async def test_input_as_string(self): + """Test input provided as string is added as user message.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = "Response" + mock_response.messages = [] + mock_agent.run = AsyncMock(return_value=mock_response) + + ctx = create_action_context( + action={ + "kind": "InvokePromptAgent", + "agent": "testAgent", + "input": "User input", + }, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert len(events) == 1 + + # Verify input was passed + call_args = mock_agent.run.call_args[0][0] + assert any(msg.text == "User input" for msg in call_args) + + @pytest.mark.asyncio + async def test_input_as_chat_message(self): + """Test input provided as ChatMessage is added directly.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + mock_agent = MagicMock() + mock_response = MagicMock() + mock_response.text = "Response" + mock_response.messages = [] + mock_agent.run = AsyncMock(return_value=mock_response) + + state = WorkflowState() + input_msg = ChatMessage(role="user", text="Chat message input") + state.set("Local.inputMsg", input_msg) + + ctx = create_action_context( + action={ + "kind": "InvokePromptAgent", + "agent": "testAgent", + "input": "=Local.inputMsg", + }, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert len(events) == 1 + + @pytest.mark.asyncio + async def test_agent_without_run_methods(self): + """Test agent without run or run_stream methods logs error.""" + from unittest.mock import MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + mock_agent = MagicMock(spec=[]) # No run or run_stream + + ctx = create_action_context( + action={"kind": "InvokePromptAgent", "agent": "testAgent"}, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert events == [] + + @pytest.mark.asyncio + async def test_agent_error_raises_exception(self): + """Test that agent errors are propagated.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + mock_agent = MagicMock(spec=["run"]) + mock_agent.run = AsyncMock(side_effect=ValueError("Agent error")) + + ctx = create_action_context( + action={"kind": "InvokePromptAgent", "agent": "testAgent"}, + agents={"testAgent": mock_agent}, + ) + + with pytest.raises(ValueError, match="Agent error"): + [event async for event in handle_invoke_prompt_agent(ctx)] + + @pytest.mark.asyncio + async def test_conversation_history_included(self): + """Test that conversation history is included in messages.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._types import ChatMessage + + from agent_framework_declarative._workflows._actions_agents import handle_invoke_prompt_agent + + mock_agent = MagicMock(spec=["run"]) + mock_response = MagicMock() + mock_response.text = "Response" + mock_response.messages = [] + mock_agent.run = AsyncMock(return_value=mock_response) + + state = WorkflowState() + state.set( + "conversation.messages", + [ + ChatMessage(role="user", text="Previous message"), + ChatMessage(role="assistant", text="Previous response"), + ], + ) + + ctx = create_action_context( + action={"kind": "InvokePromptAgent", "agent": "testAgent"}, + state=state, + agents={"testAgent": mock_agent}, + ) + + events = [event async for event in handle_invoke_prompt_agent(ctx)] + assert len(events) == 1 + + # Verify history was passed + call_args = mock_agent.run.call_args[0][0] + assert len(call_args) >= 2 diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 2d31a66d58..afcc7349e3 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -919,3 +919,423 @@ def test_mcp_tool_with_remote_connection_with_endpoint(self): assert mcp_tool.additional_properties is not None conn = mcp_tool.additional_properties["connection"] assert conn["endpoint"] == "https://auth.example.com" + + +class TestAgentFactoryFilePath: + """Tests for AgentFactory file path operations.""" + + def test_create_agent_from_yaml_path_file_not_found(self, tmp_path): + """Test that nonexistent file raises DeclarativeLoaderError.""" + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import DeclarativeLoaderError + + factory = AgentFactory() + with pytest.raises(DeclarativeLoaderError, match="YAML file not found"): + factory.create_agent_from_yaml_path(tmp_path / "nonexistent.yaml") + + def test_create_agent_from_yaml_path_with_string_path(self, tmp_path): + """Test create_agent_from_yaml_path accepts string path.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_file = tmp_path / "agent.yaml" + yaml_file.write_text(""" +kind: Prompt +name: FileAgent +instructions: Test agent from file +""") + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = factory.create_agent_from_yaml_path(str(yaml_file)) + + assert agent.name == "FileAgent" + + def test_create_agent_from_yaml_path_with_path_object(self, tmp_path): + """Test create_agent_from_yaml_path accepts Path object.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_file = tmp_path / "agent.yaml" + yaml_file.write_text(""" +kind: Prompt +name: PathAgent +instructions: Test agent from Path +""") + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = factory.create_agent_from_yaml_path(yaml_file) + + assert agent.name == "PathAgent" + + +class TestAgentFactoryAsyncMethods: + """Tests for AgentFactory async methods.""" + + @pytest.mark.asyncio + async def test_create_agent_from_yaml_path_async_file_not_found(self, tmp_path): + """Test async version raises DeclarativeLoaderError for nonexistent file.""" + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import DeclarativeLoaderError + + factory = AgentFactory() + with pytest.raises(DeclarativeLoaderError, match="YAML file not found"): + await factory.create_agent_from_yaml_path_async(tmp_path / "nonexistent.yaml") + + @pytest.mark.asyncio + async def test_create_agent_from_yaml_async_with_client(self): + """Test async creation with pre-configured client.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: AsyncAgent +instructions: Test async agent +""" + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = await factory.create_agent_from_yaml_async(yaml_content) + + assert agent.name == "AsyncAgent" + + @pytest.mark.asyncio + async def test_create_agent_from_dict_async_with_client(self): + """Test async dict creation with pre-configured client.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + agent_def = { + "kind": "Prompt", + "name": "AsyncDictAgent", + "instructions": "Test async dict agent", + } + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = await factory.create_agent_from_dict_async(agent_def) + + assert agent.name == "AsyncDictAgent" + + @pytest.mark.asyncio + async def test_create_agent_from_dict_async_invalid_kind_raises(self): + """Test that async version also raises for non-PromptAgent.""" + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import DeclarativeLoaderError + + agent_def = { + "kind": "Resource", + "name": "NotAnAgent", + } + + factory = AgentFactory() + with pytest.raises(DeclarativeLoaderError, match="Only definitions for a PromptAgent are supported"): + await factory.create_agent_from_dict_async(agent_def) + + @pytest.mark.asyncio + async def test_create_agent_from_yaml_path_async_with_string_path(self, tmp_path): + """Test async version accepts string path.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_file = tmp_path / "async_agent.yaml" + yaml_file.write_text(""" +kind: Prompt +name: AsyncPathAgent +instructions: Test async path agent +""") + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = await factory.create_agent_from_yaml_path_async(str(yaml_file)) + + assert agent.name == "AsyncPathAgent" + + +class TestAgentFactoryProviderLookup: + """Tests for provider configuration lookup.""" + + def test_provider_lookup_error_for_unknown_provider(self): + """Test that unknown provider raises ProviderLookupError.""" + + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import ProviderLookupError + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +model: + id: test-model + provider: UnknownProvider + apiType: UnknownApiType +""" + + factory = AgentFactory() + with pytest.raises(ProviderLookupError, match="Unsupported provider type"): + factory.create_agent_from_yaml(yaml_content) + + def test_additional_mappings_override_default(self): + """Test that additional_mappings can extend provider configurations.""" + from agent_framework_declarative import AgentFactory + + # Define a custom provider mapping + custom_mappings = { + "CustomProvider.Chat": { + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_id_field": "model_id", + }, + } + + factory = AgentFactory(additional_mappings=custom_mappings) + + # The custom mapping should be available + assert "CustomProvider.Chat" in factory.additional_mappings + + +class TestAgentFactoryConnectionHandling: + """Tests for connection handling in AgentFactory.""" + + def test_reference_connection_requires_connections_dict(self): + """Test that ReferenceConnection without connections dict raises.""" + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +model: + id: gpt-4 + provider: OpenAI + apiType: Chat + connection: + kind: reference + name: my-connection +""" + + factory = AgentFactory() # No connections provided + with pytest.raises(ValueError, match="Connections must be provided to resolve ReferenceConnection"): + factory.create_agent_from_yaml(yaml_content) + + def test_reference_connection_not_found_raises(self): + """Test that missing ReferenceConnection raises.""" + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +model: + id: gpt-4 + provider: OpenAI + apiType: Chat + connection: + kind: reference + name: missing-connection +""" + + factory = AgentFactory(connections={"other-connection": "value"}) + with pytest.raises(ValueError, match="not found in provided connections"): + factory.create_agent_from_yaml(yaml_content) + + def test_model_without_id_uses_provided_client(self): + """Test that model without id uses the provided chat_client.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +model: + provider: OpenAI +""" + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = factory.create_agent_from_yaml(yaml_content) + + assert agent is not None + + def test_model_without_id_and_no_client_raises(self): + """Test that model without id and no client raises.""" + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import DeclarativeLoaderError + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +model: + provider: OpenAI +""" + + factory = AgentFactory() # No chat_client + with pytest.raises(DeclarativeLoaderError, match="ChatClient must be provided"): + factory.create_agent_from_yaml(yaml_content) + + +class TestAgentFactoryChatOptions: + """Tests for chat options parsing.""" + + def test_parse_chat_options_with_all_fields(self): + """Test parsing all ModelOptions fields into chat options dict.""" + from agent_framework_declarative._loader import AgentFactory + from agent_framework_declarative._models import Model, ModelOptions + + factory = AgentFactory() + + # Create a Model with all options set + options = ModelOptions( + temperature=0.7, + maxOutputTokens=1000, + topP=0.9, + frequencyPenalty=0.5, + presencePenalty=0.3, + seed=42, + stopSequences=["STOP", "END"], + allowMultipleToolCalls=True, + ) + options.additionalProperties["chatToolMode"] = "auto" + + model = Model(id="gpt-4", options=options) + + # Parse the options + chat_options = factory._parse_chat_options(model) + + # Verify all options are parsed correctly + assert chat_options.get("temperature") == 0.7 + assert chat_options.get("max_tokens") == 1000 + assert chat_options.get("top_p") == 0.9 + assert chat_options.get("frequency_penalty") == 0.5 + assert chat_options.get("presence_penalty") == 0.3 + assert chat_options.get("seed") == 42 + assert chat_options.get("stop") == ["STOP", "END"] + assert chat_options.get("allow_multiple_tool_calls") is True + assert chat_options.get("tool_choice") == "auto" + + def test_parse_chat_options_empty_model(self): + """Test that missing model options returns empty dict.""" + from agent_framework_declarative._loader import AgentFactory + + factory = AgentFactory() + result = factory._parse_chat_options(None) + assert result == {} + + def test_parse_chat_options_with_additional_properties(self): + """Test that additional properties are passed through.""" + from agent_framework_declarative._loader import AgentFactory + from agent_framework_declarative._models import Model, ModelOptions + + factory = AgentFactory() + + # Create a Model with additional properties + options = ModelOptions(temperature=0.5) + options.additionalProperties["customOption"] = "customValue" + + model = Model(id="gpt-4", options=options) + + # Parse the options + chat_options = factory._parse_chat_options(model) + + # Verify additional properties are preserved + assert "additional_chat_options" in chat_options + assert chat_options["additional_chat_options"].get("customOption") == "customValue" + + +class TestAgentFactoryToolParsing: + """Tests for tool parsing edge cases.""" + + def test_parse_tools_returns_none_for_empty_list(self): + """Test that empty tools list returns None.""" + from agent_framework_declarative._loader import AgentFactory + + factory = AgentFactory() + result = factory._parse_tools(None) + assert result is None + + result = factory._parse_tools([]) + assert result is None + + def test_parse_function_tool_with_bindings(self): + """Test parsing FunctionTool with bindings.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +tools: + - kind: function + name: my_function + description: A test function + bindings: + - name: my_binding +""" + + def my_function(): + return "result" + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client, bindings={"my_binding": my_function}) + agent = factory.create_agent_from_yaml(yaml_content) + + # Should have parsed the tool with binding + tools = agent.default_options.get("tools", []) + assert len(tools) == 1 + + def test_parse_file_search_tool_with_all_options(self): + """Test parsing FileSearchTool with ranker and filters.""" + from unittest.mock import MagicMock + + from agent_framework import HostedFileSearchTool + + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: TestAgent +instructions: Test agent +tools: + - kind: file_search + name: search + description: Search files + vectorStoreIds: + - vs_123 + ranker: semantic + scoreThreshold: 0.8 + maximumResultCount: 10 + filters: + type: document +""" + + mock_client = MagicMock() + factory = AgentFactory(chat_client=mock_client) + agent = factory.create_agent_from_yaml(yaml_content) + + # Find the file search tool + tools = agent.default_options.get("tools", []) + file_search_tools = [t for t in tools if isinstance(t, HostedFileSearchTool)] + assert len(file_search_tools) == 1 + + def test_parse_unsupported_tool_kind_raises(self): + """Test that unsupported tool kind raises ValueError.""" + from agent_framework_declarative._loader import AgentFactory + from agent_framework_declarative._models import CustomTool + + factory = AgentFactory() + custom_tool = CustomTool(kind="custom", name="test") + + with pytest.raises(ValueError, match="Unsupported tool kind"): + factory._parse_tool(custom_tool) diff --git a/python/packages/declarative/tests/test_function_tool_executor.py b/python/packages/declarative/tests/test_function_tool_executor.py new file mode 100644 index 0000000000..ffd7bce48f --- /dev/null +++ b/python/packages/declarative/tests/test_function_tool_executor.py @@ -0,0 +1,717 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for InvokeFunctionTool executor. + +These tests verify: +- Basic function invocation (sync and async) +- Expression evaluation for functionName and arguments +- Output formatting (messages and result) +- Error handling (function not found, execution errors) +- WorkflowFactory registration +""" + + +import pytest + +from agent_framework_declarative._workflows import ( + DeclarativeWorkflowBuilder, + InvokeFunctionToolExecutor, + ToolApprovalRequest, + ToolApprovalResponse, + ToolApprovalState, + ToolInvocationResult, + WorkflowFactory, +) + + +class TestInvokeFunctionToolExecutor: + """Tests for InvokeFunctionToolExecutor.""" + + @pytest.mark.asyncio + async def test_basic_sync_function_invocation(self): + """Test invoking a simple synchronous function.""" + + def get_weather(location: str, unit: str = "F") -> dict: + return {"temp": 72, "unit": unit, "location": location} + + yaml_def = { + "name": "function_tool_test", + "actions": [ + {"kind": "SetValue", "id": "set_location", "path": "Local.city", "value": "Seattle"}, + { + "kind": "InvokeFunctionTool", + "id": "call_weather", + "functionName": "get_weather", + "arguments": {"location": "=Local.city", "unit": "C"}, + "output": {"result": "Local.weatherData"}, + }, + # Use SendActivity to output the result so we can check it + {"kind": "SendActivity", "id": "output_location", "activity": {"text": "=Local.weatherData.location"}}, + {"kind": "SendActivity", "id": "output_unit", "activity": {"text": "=Local.weatherData.unit"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"get_weather": get_weather}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Verify the function was called with correct arguments + assert "Seattle" in outputs # location + assert "C" in outputs # unit + + @pytest.mark.asyncio + async def test_async_function_invocation(self): + """Test invoking an async function.""" + + async def fetch_data(url: str) -> dict: + return {"url": url, "status": "success"} + + yaml_def = { + "name": "async_function_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "fetch", + "functionName": "fetch_data", + "arguments": {"url": "https://example.com/api"}, + "output": {"result": "Local.response"}, + }, + {"kind": "SendActivity", "id": "output_url", "activity": {"text": "=Local.response.url"}}, + {"kind": "SendActivity", "id": "output_status", "activity": {"text": "=Local.response.status"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"fetch_data": fetch_data}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "https://example.com/api" in outputs + assert "success" in outputs + + @pytest.mark.asyncio + async def test_expression_function_name(self): + """Test dynamic function name via expression.""" + + def tool_a() -> str: + return "result_a" + + def tool_b() -> str: + return "result_b" + + yaml_def = { + "name": "dynamic_function_name_test", + "actions": [ + {"kind": "SetValue", "id": "set_tool", "path": "Local.toolName", "value": "tool_b"}, + { + "kind": "InvokeFunctionTool", + "id": "dynamic_call", + "functionName": "=Local.toolName", + "arguments": {}, + "output": {"result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"tool_a": tool_a, "tool_b": tool_b}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "result_b" in outputs + + @pytest.mark.asyncio + async def test_function_not_found(self): + """Test error handling when function is not in registry.""" + yaml_def = { + "name": "function_not_found_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_missing", + "functionName": "nonexistent_function", + "arguments": {}, + "output": {"result": "Local.result"}, + }, + # Check if error is stored + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result.error"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={}) # Empty registry + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Result should contain error info + assert "not found" in outputs[0].lower() + + @pytest.mark.asyncio + async def test_function_execution_error(self): + """Test error handling when function raises exception.""" + + def failing_function() -> str: + raise ValueError("Intentional test error") + + yaml_def = { + "name": "function_error_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_failing", + "functionName": "failing_function", + "arguments": {}, + "output": {"result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result.error"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"failing_function": failing_function}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Result should contain error info + assert "Intentional test error" in outputs[0] + + @pytest.mark.asyncio + async def test_function_with_no_output_config(self): + """Test that function works even without output configuration.""" + + counter = {"value": 0} + + def increment() -> int: + counter["value"] += 1 + return counter["value"] + + yaml_def = { + "name": "no_output_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "increment_call", + "functionName": "increment", + "arguments": {}, + # No output configuration + }, + {"kind": "SendActivity", "id": "done", "activity": {"text": "Done"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"increment": increment}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Workflow should complete + assert "Done" in outputs + # Function should have been called + assert counter["value"] == 1 + + +class TestInvokeFunctionToolWithWorkflowFactory: + """Tests for InvokeFunctionTool with WorkflowFactory registration.""" + + @pytest.mark.asyncio + async def test_register_tool_method(self): + """Test registering tools via WorkflowFactory.register_tool().""" + + def multiply(a: int, b: int) -> int: + return a * b + + yaml_content = """ +name: factory_tool_test +actions: + - kind: InvokeFunctionTool + id: multiply_call + functionName: multiply + arguments: + a: 6 + b: 7 + output: + result: Local.product + - kind: SendActivity + id: output + activity: + text: =Local.product +""" + factory = WorkflowFactory().register_tool("multiply", multiply) + workflow = factory.create_workflow_from_yaml(yaml_content) + + events = await workflow.run({}) + outputs = events.get_outputs() + + # PowerFx outputs integers as floats, so we check for 42 or 42.0 + assert any("42" in out for out in outputs) + + @pytest.mark.asyncio + async def test_fluent_registration(self): + """Test fluent chaining for tool registration.""" + + def add(a: int, b: int) -> int: + return a + b + + def subtract(a: int, b: int) -> int: + return a - b + + yaml_content = """ +name: fluent_test +actions: + - kind: InvokeFunctionTool + id: add_call + functionName: add + arguments: + a: 10 + b: 5 + output: + result: Local.sum + - kind: InvokeFunctionTool + id: subtract_call + functionName: subtract + arguments: + a: 10 + b: 5 + output: + result: Local.diff + - kind: SendActivity + id: output_sum + activity: + text: =Local.sum + - kind: SendActivity + id: output_diff + activity: + text: =Local.diff +""" + factory = WorkflowFactory().register_tool("add", add).register_tool("subtract", subtract) + + workflow = factory.create_workflow_from_yaml(yaml_content) + + events = await workflow.run({}) + outputs = events.get_outputs() + + # PowerFx outputs integers as floats, so we check for 15 or 15.0 + assert any("15" in out for out in outputs) # sum + assert any("5" in out for out in outputs) # diff + + +class TestToolInvocationResult: + """Tests for ToolInvocationResult dataclass.""" + + def test_success_result(self): + """Test creating a successful result.""" + result = ToolInvocationResult( + success=True, + result={"data": "value"}, + messages=[], + ) + assert result.success is True + assert result.result == {"data": "value"} + assert result.rejected is False + assert result.error is None + + def test_error_result(self): + """Test creating an error result.""" + result = ToolInvocationResult( + success=False, + error="Function failed", + ) + assert result.success is False + assert result.error == "Function failed" + assert result.result is None + + def test_rejected_result(self): + """Test creating a rejected result.""" + result = ToolInvocationResult( + success=False, + rejected=True, + rejection_reason="User denied approval", + ) + assert result.success is False + assert result.rejected is True + assert result.rejection_reason == "User denied approval" + + +class TestToolApprovalTypes: + """Tests for approval-related dataclasses.""" + + def test_approval_request(self): + """Test creating an approval request.""" + request = ToolApprovalRequest( + request_id="test-123", + function_name="dangerous_operation", + arguments={"target": "production"}, + conversation_id="conv-456", + ) + assert request.request_id == "test-123" + assert request.function_name == "dangerous_operation" + assert request.arguments == {"target": "production"} + assert request.conversation_id == "conv-456" + + def test_approval_response_approved(self): + """Test creating an approved response.""" + response = ToolApprovalResponse(approved=True) + assert response.approved is True + assert response.reason is None + + def test_approval_response_rejected(self): + """Test creating a rejected response.""" + response = ToolApprovalResponse(approved=False, reason="Not authorized") + assert response.approved is False + assert response.reason == "Not authorized" + + def test_approval_state(self): + """Test creating approval state for yield/resume.""" + state = ToolApprovalState( + function_name="delete_user", + arguments={"user_id": "123"}, + output_messages_var="Local.messages", + output_result_var="Local.result", + conversation_id="conv-789", + ) + assert state.function_name == "delete_user" + assert state.arguments == {"user_id": "123"} + assert state.output_messages_var == "Local.messages" + assert state.output_result_var == "Local.result" + assert state.conversation_id == "conv-789" + + +class TestInvokeFunctionToolEdgeCases: + """Tests for edge cases and error handling.""" + + def test_missing_function_name_field_raises_validation_error(self): + """Test that missing functionName raises validation error at build time.""" + yaml_def = { + "name": "missing_function_name_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "no_name", + # Missing functionName field + "arguments": {}, + }, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={}) + + # Should raise validation error + with pytest.raises(ValueError, match="missing required field 'functionName'"): + builder.build() + + @pytest.mark.asyncio + async def test_empty_function_name_expression(self): + """Test handling when functionName expression evaluates to empty.""" + yaml_def = { + "name": "empty_function_name_test", + "actions": [ + {"kind": "SetValue", "id": "set_empty", "path": "Local.toolName", "value": ""}, + { + "kind": "InvokeFunctionTool", + "id": "empty_name", + "functionName": "=Local.toolName", + "arguments": {}, + }, + {"kind": "SendActivity", "id": "done", "activity": {"text": "Completed"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Should complete without crashing + assert "Completed" in outputs + + @pytest.mark.asyncio + async def test_messages_output_configuration(self): + """Test that messages output stores ChatMessage list.""" + + def simple_func(x: int) -> int: + return x * 2 + + yaml_def = { + "name": "messages_output_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_func", + "functionName": "simple_func", + "arguments": {"x": 5}, + "output": { + "messages": "Local.toolMessages", + "result": "Local.result", + }, + }, + {"kind": "SendActivity", "id": "output_result", "activity": {"text": "=Local.result"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"simple_func": simple_func}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Result should be doubled + assert any("10" in out for out in outputs) + + @pytest.mark.asyncio + async def test_function_returning_none(self): + """Test handling function that returns None.""" + + def returns_none() -> None: + pass + + yaml_def = { + "name": "returns_none_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_none", + "functionName": "returns_none", + "arguments": {}, + "output": {"result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "done", "activity": {"text": "Completed"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"returns_none": returns_none}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "Completed" in outputs + + @pytest.mark.asyncio + async def test_function_with_complex_return_type(self): + """Test function returning complex nested data.""" + + def complex_return() -> dict: + return { + "nested": { + "array": [1, 2, 3], + "string": "test", + }, + "boolean": True, + "number": 42.5, + } + + yaml_def = { + "name": "complex_return_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_complex", + "functionName": "complex_return", + "arguments": {}, + "output": {"result": "Local.data"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.data.nested.string"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"complex_return": complex_return}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "test" in outputs + + @pytest.mark.asyncio + async def test_function_with_list_argument(self): + """Test passing list as argument.""" + + def sum_list(numbers: list) -> int: + return sum(numbers) + + yaml_def = { + "name": "list_argument_test", + "actions": [ + {"kind": "SetValue", "id": "set_list", "path": "Local.numbers", "value": [1, 2, 3, 4, 5]}, + { + "kind": "InvokeFunctionTool", + "id": "call_sum", + "functionName": "sum_list", + "arguments": {"numbers": "=Local.numbers"}, + "output": {"result": "Local.total"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.total"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"sum_list": sum_list}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert any("15" in out for out in outputs) + + @pytest.mark.asyncio + async def test_conversation_id_expression(self): + """Test conversationId field with expression.""" + + def echo_id(msg: str) -> str: + return msg + + yaml_def = { + "name": "conversation_id_test", + "actions": [ + {"kind": "SetValue", "id": "set_conv_id", "path": "Local.convId", "value": "conv-123"}, + { + "kind": "InvokeFunctionTool", + "id": "call_with_conv_id", + "functionName": "echo_id", + "conversationId": "=Local.convId", + "arguments": {"msg": "hello"}, + "output": {"result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"echo_id": echo_id}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "hello" in outputs + + @pytest.mark.asyncio + async def test_function_with_only_result_output(self): + """Test output config with only result, no messages.""" + + def double(x: int) -> int: + return x * 2 + + yaml_def = { + "name": "result_only_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_double", + "functionName": "double", + "arguments": {"x": 21}, + "output": {"result": "Local.doubled"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.doubled"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"double": double}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert any("42" in out for out in outputs) + + @pytest.mark.asyncio + async def test_function_with_only_messages_output(self): + """Test output config with only messages, no result.""" + + def simple() -> str: + return "done" + + yaml_def = { + "name": "messages_only_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_simple", + "functionName": "simple", + "arguments": {}, + "output": {"messages": "Local.msgs"}, + }, + {"kind": "SendActivity", "id": "done", "activity": {"text": "Completed"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"simple": simple}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "Completed" in outputs + + @pytest.mark.asyncio + async def test_function_string_return(self): + """Test function that returns a simple string.""" + + def greet(name: str) -> str: + return f"Hello, {name}!" + + yaml_def = { + "name": "string_return_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_greet", + "functionName": "greet", + "arguments": {"name": "World"}, + "output": {"result": "Local.greeting"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.greeting"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"greet": greet}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "Hello, World!" in outputs + + +class TestInvokeFunctionToolBuilder: + """Tests for InvokeFunctionTool executor registration in builder.""" + + def test_executor_registered_in_all_executors(self): + """Test that InvokeFunctionTool is registered in ALL_ACTION_EXECUTORS.""" + from agent_framework_declarative._workflows import ALL_ACTION_EXECUTORS + + assert "InvokeFunctionTool" in ALL_ACTION_EXECUTORS + assert ALL_ACTION_EXECUTORS["InvokeFunctionTool"] == InvokeFunctionToolExecutor + + def test_builder_creates_tool_executor(self): + """Test that builder creates InvokeFunctionToolExecutor for InvokeFunctionTool actions.""" + + def dummy() -> str: + return "test" + + yaml_def = { + "name": "builder_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "my_tool", + "functionName": "dummy", + "arguments": {}, + }, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"dummy": dummy}) + workflow = builder.build() + + # Verify the executor was created + assert "my_tool" in builder._executors + executor = builder._executors["my_tool"] + assert isinstance(executor, InvokeFunctionToolExecutor) diff --git a/python/packages/declarative/tests/test_powerfx_functions.py b/python/packages/declarative/tests/test_powerfx_functions.py index 050fa96786..90b13b5823 100644 --- a/python/packages/declarative/tests/test_powerfx_functions.py +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -237,6 +237,444 @@ def test_all_functions_registered(self): "Lower", "Concat", "Search", + "If", + "Or", + "And", + "Not", + "AgentMessage", + "ForAll", ] for name in expected: assert name in CUSTOM_FUNCTIONS + + +class TestMessageTextEdgeCases: + """Additional tests for message_text edge cases.""" + + def test_message_text_dict_with_text_attr_content(self): + """Test message with content that has text attribute.""" + + class ContentWithText: + def __init__(self, text: str): + self.text = text + + msg = {"role": "assistant", "content": ContentWithText("Hello from text attr")} + assert message_text(msg) == "Hello from text attr" + + def test_message_text_dict_content_non_string(self): + """Test message with non-string content.""" + msg = {"role": "assistant", "content": 42} + assert message_text(msg) == "42" + + def test_message_text_list_with_string_items(self): + """Test message_text with list of strings.""" + result = message_text(["Hello", "World"]) + assert result == "Hello World" + + def test_message_text_list_with_content_objects(self): + """Test message_text with list items having content attribute.""" + + class MessageObj: + def __init__(self, content: str): + self.content = content + + msgs = [MessageObj("Hello"), MessageObj("World")] + result = message_text(msgs) + assert result == "Hello World" + + def test_message_text_list_with_content_text_attr(self): + """Test message_text with content having text attribute.""" + + class ContentWithText: + def __init__(self, text: str): + self.text = text + + class MessageObj: + def __init__(self, content): + self.content = content + + msgs = [MessageObj(ContentWithText("Part1")), MessageObj(ContentWithText("Part2"))] + result = message_text(msgs) + assert result == "Part1 Part2" + + def test_message_text_list_with_non_string_content(self): + """Test message_text with non-string content in dicts.""" + msgs = [{"content": 123}, {"content": 456}] + result = message_text(msgs) + assert result == "123 456" + + def test_message_text_object_with_text_attr(self): + """Test message_text with object having text attribute.""" + + class ObjWithText: + text = "Direct text" + + result = message_text(ObjWithText()) + assert result == "Direct text" + + def test_message_text_object_with_content_attr(self): + """Test message_text with object having content attribute.""" + + class ObjWithContent: + content = "Direct content" + + result = message_text(ObjWithContent()) + assert result == "Direct content" + + def test_message_text_object_with_non_string_content(self): + """Test message_text with object having non-string content.""" + + class ObjWithContent: + content = None + + result = message_text(ObjWithContent()) + assert result == "" + + def test_message_text_list_with_empty_content_object(self): + """Test message with content object that evaluates to empty.""" + + class MessageObj: + content = None + + result = message_text([MessageObj()]) + assert result == "" + + +class TestAgentMessage: + """Tests for agent_message function.""" + + def test_agent_message_creates_dict(self): + """Test that AgentMessage creates correct dict.""" + from agent_framework_declarative._workflows._powerfx_functions import agent_message + + msg = agent_message("Hello") + assert msg == {"role": "assistant", "content": "Hello"} + + def test_agent_message_with_none(self): + """Test AgentMessage with None.""" + from agent_framework_declarative._workflows._powerfx_functions import agent_message + + msg = agent_message(None) + assert msg == {"role": "assistant", "content": ""} + + +class TestIfFunc: + """Tests for if_func conditional function.""" + + def test_if_true_condition(self): + """Test If with true condition.""" + from agent_framework_declarative._workflows._powerfx_functions import if_func + + assert if_func(True, "yes", "no") == "yes" + + def test_if_false_condition(self): + """Test If with false condition.""" + from agent_framework_declarative._workflows._powerfx_functions import if_func + + assert if_func(False, "yes", "no") == "no" + + def test_if_truthy_value(self): + """Test If with truthy value.""" + from agent_framework_declarative._workflows._powerfx_functions import if_func + + assert if_func(1, "yes", "no") == "yes" + assert if_func("non-empty", "yes", "no") == "yes" + + def test_if_falsy_value(self): + """Test If with falsy value.""" + from agent_framework_declarative._workflows._powerfx_functions import if_func + + assert if_func(0, "yes", "no") == "no" + assert if_func("", "yes", "no") == "no" + assert if_func(None, "yes", "no") == "no" + + def test_if_no_false_value(self): + """Test If with no false value defaults to None.""" + from agent_framework_declarative._workflows._powerfx_functions import if_func + + assert if_func(False, "yes") is None + + +class TestOrFunc: + """Tests for or_func function.""" + + def test_or_all_false(self): + """Test Or with all false values.""" + from agent_framework_declarative._workflows._powerfx_functions import or_func + + assert or_func(False, False, False) is False + + def test_or_one_true(self): + """Test Or with one true value.""" + from agent_framework_declarative._workflows._powerfx_functions import or_func + + assert or_func(False, True, False) is True + + def test_or_all_true(self): + """Test Or with all true values.""" + from agent_framework_declarative._workflows._powerfx_functions import or_func + + assert or_func(True, True, True) is True + + def test_or_empty(self): + """Test Or with no arguments.""" + from agent_framework_declarative._workflows._powerfx_functions import or_func + + assert or_func() is False + + +class TestAndFunc: + """Tests for and_func function.""" + + def test_and_all_true(self): + """Test And with all true values.""" + from agent_framework_declarative._workflows._powerfx_functions import and_func + + assert and_func(True, True, True) is True + + def test_and_one_false(self): + """Test And with one false value.""" + from agent_framework_declarative._workflows._powerfx_functions import and_func + + assert and_func(True, False, True) is False + + def test_and_all_false(self): + """Test And with all false values.""" + from agent_framework_declarative._workflows._powerfx_functions import and_func + + assert and_func(False, False, False) is False + + def test_and_empty(self): + """Test And with no arguments.""" + from agent_framework_declarative._workflows._powerfx_functions import and_func + + assert and_func() is True + + +class TestNotFunc: + """Tests for not_func function.""" + + def test_not_true(self): + """Test Not with true.""" + from agent_framework_declarative._workflows._powerfx_functions import not_func + + assert not_func(True) is False + + def test_not_false(self): + """Test Not with false.""" + from agent_framework_declarative._workflows._powerfx_functions import not_func + + assert not_func(False) is True + + def test_not_truthy(self): + """Test Not with truthy values.""" + from agent_framework_declarative._workflows._powerfx_functions import not_func + + assert not_func(1) is False + assert not_func("text") is False + + def test_not_falsy(self): + """Test Not with falsy values.""" + from agent_framework_declarative._workflows._powerfx_functions import not_func + + assert not_func(0) is True + assert not_func("") is True + assert not_func(None) is True + + +class TestIsBlankEdgeCases: + """Additional tests for is_blank edge cases.""" + + def test_is_blank_empty_dict(self): + """Test that empty dict is blank.""" + assert is_blank({}) is True + + def test_is_blank_non_empty_dict(self): + """Test that non-empty dict is not blank.""" + assert is_blank({"key": "value"}) is False + + +class TestCountRowsEdgeCases: + """Additional tests for count_rows edge cases.""" + + def test_count_rows_dict(self): + """Test counting dict items.""" + assert count_rows({"a": 1, "b": 2, "c": 3}) == 3 + + def test_count_rows_tuple(self): + """Test counting tuple items.""" + assert count_rows((1, 2, 3, 4)) == 4 + + def test_count_rows_non_iterable(self): + """Test counting non-iterable returns 0.""" + assert count_rows(42) == 0 + assert count_rows("string") == 0 + + +class TestFirstLastEdgeCases: + """Additional tests for first/last edge cases.""" + + def test_first_none(self): + """Test first with None.""" + assert first(None) is None + + def test_last_none(self): + """Test last with None.""" + assert last(None) is None + + def test_first_tuple(self): + """Test first with tuple.""" + assert first((1, 2, 3)) == 1 + + def test_last_tuple(self): + """Test last with tuple.""" + assert last((1, 2, 3)) == 3 + + +class TestFindEdgeCases: + """Additional tests for find edge cases.""" + + def test_find_none_substring(self): + """Test find with None substring.""" + assert find(None, "text") is None + + def test_find_none_text(self): + """Test find with None text.""" + assert find("sub", None) is None + + def test_find_both_none(self): + """Test find with both None.""" + assert find(None, None) is None + + +class TestLowerEdgeCases: + """Additional tests for lower edge cases.""" + + def test_lower_none(self): + """Test lower with None.""" + assert lower(None) == "" + + +class TestConcatStrings: + """Tests for concat_strings function.""" + + def test_concat_strings_basic(self): + """Test basic string concatenation.""" + from agent_framework_declarative._workflows._powerfx_functions import concat_strings + + assert concat_strings("Hello", " ", "World") == "Hello World" + + def test_concat_strings_with_none(self): + """Test concat with None values.""" + from agent_framework_declarative._workflows._powerfx_functions import concat_strings + + assert concat_strings("Hello", None, "World") == "HelloWorld" + + def test_concat_strings_empty(self): + """Test concat with no arguments.""" + from agent_framework_declarative._workflows._powerfx_functions import concat_strings + + assert concat_strings() == "" + + +class TestConcatTextEdgeCases: + """Additional tests for concat_text edge cases.""" + + def test_concat_text_none(self): + """Test concat_text with None.""" + assert concat_text(None) == "" + + def test_concat_text_non_list(self): + """Test concat_text with non-list.""" + assert concat_text("single value") == "single value" + + def test_concat_text_with_field_attr(self): + """Test concat_text with field as object attribute.""" + + class Item: + def __init__(self, name: str): + self.name = name + + items = [Item("Alice"), Item("Bob")] + assert concat_text(items, field="name", separator=", ") == "Alice, Bob" + + def test_concat_text_with_none_values(self): + """Test concat_text with None values in list.""" + items = [{"name": "Alice"}, {"name": None}, {"name": "Bob"}] + result = concat_text(items, field="name", separator=", ") + assert result == "Alice, , Bob" + + +class TestForAll: + """Tests for for_all function.""" + + def test_for_all_with_list_of_dicts(self): + """Test ForAll with list of dictionaries.""" + from agent_framework_declarative._workflows._powerfx_functions import for_all + + items = [{"name": "Alice"}, {"name": "Bob"}] + result = for_all(items, "expression") + assert result == items + + def test_for_all_with_non_dict_items(self): + """Test ForAll with non-dict items.""" + from agent_framework_declarative._workflows._powerfx_functions import for_all + + items = [1, 2, 3] + result = for_all(items, "expression") + assert result == [1, 2, 3] + + def test_for_all_with_none(self): + """Test ForAll with None.""" + from agent_framework_declarative._workflows._powerfx_functions import for_all + + assert for_all(None, "expression") == [] + + def test_for_all_with_non_list(self): + """Test ForAll with non-list.""" + from agent_framework_declarative._workflows._powerfx_functions import for_all + + assert for_all("not a list", "expression") == [] + + def test_for_all_empty_list(self): + """Test ForAll with empty list.""" + from agent_framework_declarative._workflows._powerfx_functions import for_all + + assert for_all([], "expression") == [] + + +class TestSearchTableEdgeCases: + """Additional tests for search_table edge cases.""" + + def test_search_table_none(self): + """Test search_table with None.""" + assert search_table(None, "value", "column") == [] + + def test_search_table_non_list(self): + """Test search_table with non-list.""" + assert search_table("not a list", "value", "column") == [] + + def test_search_table_with_object_attr(self): + """Test search_table with object attributes.""" + + class Item: + def __init__(self, name: str): + self.name = name + + items = [Item("Alice"), Item("Bob"), Item("Charlie")] + result = search_table(items, "Bob", "name") + assert len(result) == 1 + assert result[0].name == "Bob" + + def test_search_table_no_matching_column(self): + """Test search_table when items don't have the column.""" + items = [{"other": "value"}] + result = search_table(items, "value", "name") + assert result == [] + + def test_search_table_empty_value(self): + """Test search_table with empty search value.""" + items = [{"name": "Alice"}, {"name": "Bob"}] + result = search_table(items, "", "name") + # Empty string matches everything + assert len(result) == 2 diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index 8bad4651f0..99b1dcbeaa 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -277,3 +277,644 @@ def test_action_context_without_display_name(self): assert ctx.action_id is None assert ctx.display_name is None assert ctx.action_kind == "SetValue" + + +class TestWorkflowFactoryToolRegistration: + """Tests for tool registration.""" + + def test_register_tool_basic(self): + """Test registering a tool.""" + + def my_tool(x: int) -> int: + return x * 2 + + factory = WorkflowFactory() + result = factory.register_tool("my_tool", my_tool) + + # Should return self for fluent chaining + assert result is factory + assert "my_tool" in factory._tools + assert factory._tools["my_tool"](5) == 10 + + def test_register_multiple_tools(self): + """Test registering multiple tools with fluent chaining.""" + + def add(a: int, b: int) -> int: + return a + b + + def multiply(a: int, b: int) -> int: + return a * b + + factory = WorkflowFactory().register_tool("add", add).register_tool("multiply", multiply) + + assert "add" in factory._tools + assert "multiply" in factory._tools + assert factory._tools["add"](2, 3) == 5 + assert factory._tools["multiply"](2, 3) == 6 + + +class TestWorkflowFactoryEdgeCases: + """Tests for edge cases in workflow factory.""" + + def test_empty_actions_list(self): + """Test workflow with empty actions list.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="actions"): + factory.create_workflow_from_yaml(""" +name: empty-actions +actions: [] +""") + + def test_unknown_action_kind(self): + """Test workflow with unknown action kind.""" + factory = WorkflowFactory() + with pytest.raises((DeclarativeWorkflowError, ValueError)): + factory.create_workflow_from_yaml(""" +name: unknown-action +actions: + - kind: UnknownActionType + value: test +""") + + def test_workflow_with_description(self): + """Test workflow with description field.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: described-workflow +description: This is a test workflow +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + assert workflow is not None + assert workflow.name == "described-workflow" + + @pytest.mark.asyncio + async def test_workflow_with_expression_value(self): + """Test workflow with expression-based value.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: expression-test +actions: + - kind: SetValue + path: Local.x + value: 5 + - kind: SetValue + path: Local.y + value: =Local.x + - kind: SendActivity + activity: + text: =Local.y +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + assert any("5" in str(o) for o in outputs) + + @pytest.mark.asyncio + async def test_workflow_with_nested_if(self): + """Test workflow with nested If statements.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: nested-if-test +actions: + - kind: SetValue + path: Local.level + value: 2 + - kind: If + condition: true + then: + - kind: If + condition: true + then: + - kind: SendActivity + activity: + text: Nested condition passed +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + assert any("Nested condition passed" in str(o) for o in outputs) + + def test_load_from_string_path(self, tmp_path): + """Test loading a workflow from a string file path.""" + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text(""" +name: string-path-workflow +actions: + - kind: SetValue + path: Local.loaded + value: true +""") + + factory = WorkflowFactory() + # Pass as string instead of Path object + workflow = factory.create_workflow_from_yaml_path(str(workflow_file)) + + assert workflow is not None + assert workflow.name == "string-path-workflow" + + +class TestWorkflowFactorySwitch: + """Tests for Switch/Case action.""" + + @pytest.mark.asyncio + async def test_switch_with_matching_case(self): + """Test Switch with a matching case.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: switch-test +actions: + - kind: SetValue + path: Local.color + value: red + - kind: Switch + value: =Local.color + cases: + - match: red + actions: + - kind: SendActivity + activity: + text: Color is red + - match: blue + actions: + - kind: SendActivity + activity: + text: Color is blue +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + assert any("Color is red" in str(o) for o in outputs) + + @pytest.mark.asyncio + async def test_switch_with_default(self): + """Test Switch falling through to default.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: switch-default-test +actions: + - kind: SetValue + path: Local.color + value: green + - kind: Switch + value: =Local.color + cases: + - match: red + actions: + - kind: SendActivity + activity: + text: Red + - match: blue + actions: + - kind: SendActivity + activity: + text: Blue + default: + - kind: SendActivity + activity: + text: Unknown color +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + assert any("Unknown color" in str(o) for o in outputs) + + +class TestWorkflowFactoryMultipleActionTypes: + """Tests for workflows with multiple action types.""" + + @pytest.mark.asyncio + async def test_set_multiple_variables(self): + """Test SetMultipleVariables action.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: multi-set-test +actions: + - kind: SetMultipleVariables + variables: + - path: Local.a + value: 1 + - path: Local.b + value: 2 + - path: Local.c + value: 3 + - kind: SendActivity + activity: + text: Done +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + assert any("Done" in str(o) for o in outputs) + + @pytest.mark.asyncio + async def test_append_value(self): + """Test AppendValue action.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: append-test +actions: + - kind: SetValue + path: Local.list + value: [] + - kind: AppendValue + path: Local.list + value: first + - kind: AppendValue + path: Local.list + value: second + - kind: SendActivity + activity: + text: Done +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + assert any("Done" in str(o) for o in outputs) + + @pytest.mark.asyncio + async def test_emit_event(self): + """Test EmitEvent action.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: emit-event-test +actions: + - kind: EmitEvent + event: + name: test_event + data: + message: Hello + - kind: SendActivity + activity: + text: Event emitted +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + # Workflow should complete + assert any("Event emitted" in str(o) for o in outputs) + + +class TestWorkflowFactoryYamlErrors: + """Tests for YAML parsing error handling.""" + + def test_invalid_yaml_raises(self): + """Test that invalid YAML raises DeclarativeWorkflowError.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="Invalid YAML"): + factory.create_workflow_from_yaml(""" +name: broken-yaml +actions: + - kind: SetValue + path: Local.x + value: [unclosed bracket +""") + + def test_non_dict_workflow_raises(self): + """Test that non-dict workflow definition raises error.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="must be a dictionary"): + factory.create_workflow_from_yaml("- just a list item") + + +class TestWorkflowFactoryTriggerFormat: + """Tests for trigger-based workflow format.""" + + def test_trigger_based_workflow(self): + """Test workflow with trigger-based format.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +kind: Workflow +trigger: + kind: OnConversationStart + id: my_trigger + actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + assert workflow is not None + assert workflow.name == "my_trigger" + + def test_trigger_workflow_without_id(self): + """Test trigger workflow without id uses default name.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +kind: Workflow +trigger: + kind: OnConversationStart + actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + assert workflow is not None + assert workflow.name == "declarative_workflow" + + +class TestWorkflowFactoryAgentCreation: + """Tests for agent creation from definitions.""" + + def test_agent_creation_with_file_reference(self, tmp_path): + """Test creating agent from file reference.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + # Create a minimal agent YAML file (using Prompt kind) + agent_file = tmp_path / "test_agent.yaml" + agent_file.write_text(""" +kind: Prompt +name: TestAgent +description: A test agent +instructions: You are a test agent. +""") + + # Create a mock client and agent factory + mock_client = MagicMock() + mock_agent = MagicMock() + mock_agent.name = "TestAgent" + mock_client.create_agent.return_value = mock_agent + + agent_factory = AgentFactory(chat_client=mock_client) + + # Create workflow that references the agent + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text(f""" +kind: Workflow +agents: + TestAgent: + file: {agent_file.name} +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + factory = WorkflowFactory(agent_factory=agent_factory) + workflow = factory.create_workflow_from_yaml_path(workflow_file) + + assert workflow is not None + assert "TestAgent" in workflow._declarative_agents + + def test_agent_connection_definition_raises(self): + """Test that connection-based agent definition raises error.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="Connection-based agents"): + factory.create_workflow_from_yaml(""" +kind: Workflow +agents: + MyAgent: + connection: azure-connection +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + def test_invalid_agent_definition_raises(self): + """Test that invalid agent definition raises error.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="Invalid agent definition"): + factory.create_workflow_from_yaml(""" +kind: Workflow +agents: + MyAgent: + unknown_field: value +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + def test_preregistered_agent_not_overwritten(self): + """Test that pre-registered agents are not overwritten by definitions.""" + + class MockAgent: + name = "PreregisteredAgent" + + factory = WorkflowFactory(agents={"TestAgent": MockAgent()}) + workflow = factory.create_workflow_from_yaml(""" +kind: Workflow +agents: + TestAgent: + kind: Agent + name: OverrideAttempt +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + assert workflow._declarative_agents["TestAgent"].name == "PreregisteredAgent" + + +class TestWorkflowFactoryInputSchema: + """Tests for input schema conversion.""" + + def test_inputs_to_json_schema_basic(self): + """Test basic input schema conversion.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: input-schema-test +inputs: + name: + type: string + description: The user's name + age: + type: integer + description: The user's age +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + schema = workflow.input_schema + assert schema["type"] == "object" + assert "name" in schema["properties"] + assert "age" in schema["properties"] + assert schema["properties"]["name"]["type"] == "string" + assert schema["properties"]["age"]["type"] == "integer" + assert "name" in schema["required"] + assert "age" in schema["required"] + + def test_inputs_schema_with_optional_field(self): + """Test input schema with optional field.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: optional-input-test +inputs: + required_field: + type: string + required: true + optional_field: + type: string + required: false +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + schema = workflow.input_schema + assert "required_field" in schema["required"] + assert "optional_field" not in schema["required"] + + def test_inputs_schema_with_default_value(self): + """Test input schema with default value.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: default-input-test +inputs: + greeting: + type: string + default: Hello +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + schema = workflow.input_schema + assert schema["properties"]["greeting"]["default"] == "Hello" + + def test_inputs_schema_with_enum(self): + """Test input schema with enum values.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: enum-input-test +inputs: + color: + type: string + enum: + - red + - green + - blue +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + schema = workflow.input_schema + assert schema["properties"]["color"]["enum"] == ["red", "green", "blue"] + + def test_inputs_schema_type_mappings(self): + """Test various type mappings in input schema.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: type-mapping-test +inputs: + str_field: + type: str + int_field: + type: int + float_field: + type: float + bool_field: + type: bool + list_field: + type: list + dict_field: + type: dict +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + schema = workflow.input_schema + assert schema["properties"]["str_field"]["type"] == "string" + assert schema["properties"]["int_field"]["type"] == "integer" + assert schema["properties"]["float_field"]["type"] == "number" + assert schema["properties"]["bool_field"]["type"] == "boolean" + assert schema["properties"]["list_field"]["type"] == "array" + assert schema["properties"]["dict_field"]["type"] == "object" + + def test_inputs_schema_simple_format(self): + """Test simple input format (field: type).""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: simple-input-test +inputs: + name: string + count: integer +actions: + - kind: SetValue + path: Local.x + value: 1 +""") + + schema = workflow.input_schema + assert schema["properties"]["name"]["type"] == "string" + assert schema["properties"]["count"]["type"] == "integer" + assert "name" in schema["required"] + assert "count" in schema["required"] + + +class TestWorkflowFactoryChaining: + """Tests for fluent method chaining.""" + + def test_fluent_agent_registration(self): + """Test fluent agent registration.""" + + class MockAgent1: + name = "Agent1" + + class MockAgent2: + name = "Agent2" + + factory = WorkflowFactory().register_agent("agent1", MockAgent1()).register_agent("agent2", MockAgent2()) + + assert "agent1" in factory._agents + assert "agent2" in factory._agents + + def test_fluent_binding_registration(self): + """Test fluent binding registration.""" + + def func1(): + return 1 + + def func2(): + return 2 + + factory = WorkflowFactory().register_binding("func1", func1).register_binding("func2", func2) + + assert "func1" in factory._bindings + assert "func2" in factory._bindings + + def test_fluent_mixed_registration(self): + """Test mixed fluent registration.""" + + class MockAgent: + name = "Agent" + + def my_tool(): + return "tool" + + def my_binding(): + return "binding" + + factory = ( + WorkflowFactory() + .register_agent("agent", MockAgent()) + .register_tool("tool", my_tool) + .register_binding("binding", my_binding) + ) + + assert "agent" in factory._agents + assert "tool" in factory._tools + assert "binding" in factory._bindings diff --git a/python/packages/declarative/tests/test_workflow_handlers.py b/python/packages/declarative/tests/test_workflow_handlers.py index 88aa565c9b..6d04df1000 100644 --- a/python/packages/declarative/tests/test_workflow_handlers.py +++ b/python/packages/declarative/tests/test_workflow_handlers.py @@ -422,3 +422,694 @@ async def test_finally_always_executes(self): assert ctx.state.get("Local.try") == "ran" assert ctx.state.get("Local.finally") == "ran" + + +class TestSetValueHandlerEdgeCases: + """Tests for SetValue action edge cases.""" + + @pytest.mark.asyncio + async def test_set_value_missing_path_logs_warning(self): + """Test that missing path logs warning and returns early.""" + ctx = create_action_context({ + "kind": "SetValue", + # Missing 'path' property + "value": "test value", + }) + + handler = get_action_handler("SetValue") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + # The handler should return early without setting anything + + +class TestSetVariableHandler: + """Tests for SetVariable action handler (.NET style).""" + + @pytest.mark.asyncio + async def test_set_variable_basic(self): + """Test SetVariable with basic variable path.""" + ctx = create_action_context({ + "kind": "SetVariable", + "variable": "Local.myVar", + "value": "hello", + }) + + handler = get_action_handler("SetVariable") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + assert ctx.state.get("Local.myVar") == "hello" + + @pytest.mark.asyncio + async def test_set_variable_missing_variable_logs_warning(self): + """Test that missing variable logs warning.""" + ctx = create_action_context({ + "kind": "SetVariable", + # Missing 'variable' property + "value": "test", + }) + + handler = get_action_handler("SetVariable") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + @pytest.mark.asyncio + async def test_set_variable_adds_local_prefix(self): + """Test that variable without namespace gets Local prefix.""" + ctx = create_action_context({ + "kind": "SetVariable", + "variable": "myVar", # No namespace + "value": "test", + }) + + handler = get_action_handler("SetVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Should be stored under Local namespace + assert ctx.state.get("Local.myVar") == "test" + + +class TestAppendValueHandlerEdgeCases: + """Tests for AppendValue action edge cases.""" + + @pytest.mark.asyncio + async def test_append_value_missing_path_logs_warning(self): + """Test that missing path logs warning.""" + ctx = create_action_context({ + "kind": "AppendValue", + # Missing 'path' property + "value": "item", + }) + + handler = get_action_handler("AppendValue") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + +class TestSendActivityHandlerEdgeCases: + """Tests for SendActivity action edge cases.""" + + @pytest.mark.asyncio + async def test_send_activity_simple_string_form(self): + """Test SendActivity with simple string activity.""" + ctx = create_action_context({ + "kind": "SendActivity", + "activity": "Hello from simple string!", + }) + + handler = get_action_handler("SendActivity") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], TextOutputEvent) + assert events[0].text == "Hello from simple string!" + + @pytest.mark.asyncio + async def test_send_activity_with_attachments(self): + """Test SendActivity with attachments.""" + from agent_framework_declarative._workflows._handlers import AttachmentOutputEvent + + ctx = create_action_context({ + "kind": "SendActivity", + "activity": { + "text": "See attachment", + "attachments": [ + {"content": "file content", "contentType": "text/plain"}, + ], + }, + }) + + handler = get_action_handler("SendActivity") + events = [e async for e in handler(ctx)] + + assert len(events) == 2 + assert isinstance(events[0], TextOutputEvent) + assert events[0].text == "See attachment" + assert isinstance(events[1], AttachmentOutputEvent) + assert events[1].content == "file content" + assert events[1].content_type == "text/plain" + + @pytest.mark.asyncio + async def test_send_activity_empty_text(self): + """Test SendActivity with empty/None text.""" + ctx = create_action_context({ + "kind": "SendActivity", + "activity": { + "text": None, + }, + }) + + handler = get_action_handler("SendActivity") + events = [e async for e in handler(ctx)] + + # Should not produce any events for None text + assert len(events) == 0 + + +class TestEmitEventHandlerEdgeCases: + """Tests for EmitEvent action edge cases.""" + + @pytest.mark.asyncio + async def test_emit_event_missing_name_logs_warning(self): + """Test that missing event name logs warning.""" + ctx = create_action_context({ + "kind": "EmitEvent", + "event": { + # Missing 'name' property + "data": {"key": "value"}, + }, + }) + + handler = get_action_handler("EmitEvent") + events = [e async for e in handler(ctx)] + + # Should not emit any event without name + assert len(events) == 0 + + +class TestSetTextVariableHandler: + """Tests for SetTextVariable action handler.""" + + @pytest.mark.asyncio + async def test_set_text_variable_with_interpolation(self): + """Test SetTextVariable with variable interpolation.""" + ctx = create_action_context( + { + "kind": "SetTextVariable", + "variable": "Local.message", + "value": "Hello {name}!", + }, + inputs={"name": "World"}, + ) + # Set the variable that will be interpolated + ctx.state.set("Local.name", "World") + + handler = get_action_handler("SetTextVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("Local.message") == "Hello World!" + + @pytest.mark.asyncio + async def test_set_text_variable_missing_variable(self): + """Test SetTextVariable with missing variable property.""" + ctx = create_action_context({ + "kind": "SetTextVariable", + # Missing 'variable' property + "value": "test text", + }) + + handler = get_action_handler("SetTextVariable") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + @pytest.mark.asyncio + async def test_set_text_variable_non_string_value(self): + """Test SetTextVariable with non-string value.""" + ctx = create_action_context({ + "kind": "SetTextVariable", + "variable": "Local.num", + "value": 42, # Non-string + }) + + handler = get_action_handler("SetTextVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("Local.num") == 42 + + +class TestSetMultipleVariablesHandler: + """Tests for SetMultipleVariables action handler.""" + + @pytest.mark.asyncio + async def test_set_multiple_variables_basic(self): + """Test setting multiple variables at once.""" + ctx = create_action_context({ + "kind": "SetMultipleVariables", + "variables": [ + {"variable": "Local.var1", "value": "one"}, + {"variable": "Local.var2", "value": "two"}, + {"variable": "Local.var3", "value": 3}, + ], + }) + + handler = get_action_handler("SetMultipleVariables") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("Local.var1") == "one" + assert ctx.state.get("Local.var2") == "two" + assert ctx.state.get("Local.var3") == 3 + + @pytest.mark.asyncio + async def test_set_multiple_variables_missing_variable_skips(self): + """Test that missing variable property skips that entry.""" + ctx = create_action_context({ + "kind": "SetMultipleVariables", + "variables": [ + {"variable": "Local.valid", "value": "ok"}, + {"value": "skipped"}, # Missing 'variable' property + {"variable": "Local.also_valid", "value": "also ok"}, + ], + }) + + handler = get_action_handler("SetMultipleVariables") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("Local.valid") == "ok" + assert ctx.state.get("Local.also_valid") == "also ok" + + @pytest.mark.asyncio + async def test_set_multiple_variables_uses_path_field(self): + """Test that variables can use 'path' field instead of 'variable'.""" + ctx = create_action_context({ + "kind": "SetMultipleVariables", + "variables": [ + {"path": "Local.using_path", "value": "path value"}, + ], + }) + + handler = get_action_handler("SetMultipleVariables") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Note: The handler uses 'variable' field, not 'path' + # So this won't set anything unless the handler supports both + + +class TestResetVariableHandler: + """Tests for ResetVariable action handler.""" + + @pytest.mark.asyncio + async def test_reset_variable_basic(self): + """Test resetting a variable to None.""" + ctx = create_action_context({ + "kind": "ResetVariable", + "variable": "Local.toReset", + }) + ctx.state.set("Local.toReset", "some value") + + handler = get_action_handler("ResetVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("Local.toReset") is None + + @pytest.mark.asyncio + async def test_reset_variable_missing_variable(self): + """Test ResetVariable with missing variable property.""" + ctx = create_action_context({ + "kind": "ResetVariable", + # Missing 'variable' property + }) + + handler = get_action_handler("ResetVariable") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + +class TestClearAllVariablesHandler: + """Tests for ClearAllVariables action handler.""" + + @pytest.mark.asyncio + async def test_clear_all_variables(self): + """Test clearing all Local-scoped variables.""" + ctx = create_action_context({ + "kind": "ClearAllVariables", + }) + ctx.state.set("Local.var1", "value1") + ctx.state.set("Local.var2", "value2") + + handler = get_action_handler("ClearAllVariables") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("Local.var1") is None + assert ctx.state.get("Local.var2") is None + + +class TestCreateConversationHandler: + """Tests for CreateConversation action handler.""" + + @pytest.mark.asyncio + async def test_create_conversation_basic(self): + """Test creating a conversation.""" + ctx = create_action_context({ + "kind": "CreateConversation", + "conversationId": "Local.convId", + }) + + handler = get_action_handler("CreateConversation") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Should have generated and stored a conversation ID + conv_id = ctx.state.get("Local.convId") + assert conv_id is not None + # ID should be a UUID string + import uuid + + uuid.UUID(conv_id) # This will raise if not valid UUID + + @pytest.mark.asyncio + async def test_create_conversation_without_output_var(self): + """Test creating a conversation without output variable.""" + ctx = create_action_context({ + "kind": "CreateConversation", + }) + + handler = get_action_handler("CreateConversation") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Should still create conversation in System.conversations + conversations = ctx.state.get("System.conversations") + assert conversations is not None + assert len(conversations) == 1 + + +class TestAddConversationMessageHandler: + """Tests for AddConversationMessage action handler.""" + + @pytest.mark.asyncio + async def test_add_conversation_message(self): + """Test adding a message to a conversation.""" + ctx = create_action_context({ + "kind": "AddConversationMessage", + "conversationId": "test-conv-123", + "message": { + "role": "user", + "content": "Hello!", + }, + }) + + handler = get_action_handler("AddConversationMessage") + _events = [e async for e in handler(ctx)] # noqa: F841 + + conversations = ctx.state.get("System.conversations") + assert conversations is not None + assert "test-conv-123" in conversations + messages = conversations["test-conv-123"]["messages"] + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["content"] == "Hello!" + + @pytest.mark.asyncio + async def test_add_conversation_message_missing_id(self): + """Test AddConversationMessage with missing conversationId.""" + ctx = create_action_context({ + "kind": "AddConversationMessage", + # Missing 'conversationId' + "message": { + "role": "user", + "content": "Hello!", + }, + }) + + handler = get_action_handler("AddConversationMessage") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + +class TestCopyConversationMessagesHandler: + """Tests for CopyConversationMessages action handler.""" + + @pytest.mark.asyncio + async def test_copy_conversation_messages(self): + """Test copying messages between conversations.""" + ctx = create_action_context({ + "kind": "CopyConversationMessages", + "sourceConversationId": "source-conv", + "targetConversationId": "target-conv", + }) + + # Set up source conversation with messages + ctx.state.set( + "System.conversations", + { + "source-conv": { + "id": "source-conv", + "messages": [ + {"role": "user", "content": "msg1"}, + {"role": "assistant", "content": "msg2"}, + ], + }, + }, + ) + + handler = get_action_handler("CopyConversationMessages") + _events = [e async for e in handler(ctx)] # noqa: F841 + + conversations = ctx.state.get("System.conversations") + target_messages = conversations["target-conv"]["messages"] + assert len(target_messages) == 2 + assert target_messages[0]["content"] == "msg1" + assert target_messages[1]["content"] == "msg2" + + @pytest.mark.asyncio + async def test_copy_conversation_messages_with_count(self): + """Test copying limited number of messages.""" + ctx = create_action_context({ + "kind": "CopyConversationMessages", + "sourceConversationId": "source-conv", + "targetConversationId": "target-conv", + "count": 1, + }) + + ctx.state.set( + "System.conversations", + { + "source-conv": { + "id": "source-conv", + "messages": [ + {"role": "user", "content": "msg1"}, + {"role": "assistant", "content": "msg2"}, + {"role": "user", "content": "msg3"}, + ], + }, + }, + ) + + handler = get_action_handler("CopyConversationMessages") + _events = [e async for e in handler(ctx)] # noqa: F841 + + conversations = ctx.state.get("System.conversations") + target_messages = conversations["target-conv"]["messages"] + # Should only have the last message (count=1 gets last N messages) + assert len(target_messages) == 1 + assert target_messages[0]["content"] == "msg3" + + @pytest.mark.asyncio + async def test_copy_conversation_messages_missing_ids(self): + """Test CopyConversationMessages with missing IDs.""" + ctx = create_action_context({ + "kind": "CopyConversationMessages", + # Missing both IDs + }) + + handler = get_action_handler("CopyConversationMessages") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + +class TestRetrieveConversationMessagesHandler: + """Tests for RetrieveConversationMessages action handler.""" + + @pytest.mark.asyncio + async def test_retrieve_conversation_messages(self): + """Test retrieving messages from a conversation.""" + ctx = create_action_context({ + "kind": "RetrieveConversationMessages", + "conversationId": "test-conv", + "output": { + "messages": "Local.retrievedMessages", + }, + }) + + ctx.state.set( + "System.conversations", + { + "test-conv": { + "id": "test-conv", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ], + }, + }, + ) + + handler = get_action_handler("RetrieveConversationMessages") + _events = [e async for e in handler(ctx)] # noqa: F841 + + retrieved = ctx.state.get("Local.retrievedMessages") + assert len(retrieved) == 2 + assert retrieved[0]["content"] == "Hello" + + @pytest.mark.asyncio + async def test_retrieve_conversation_messages_with_count(self): + """Test retrieving limited messages.""" + ctx = create_action_context({ + "kind": "RetrieveConversationMessages", + "conversationId": "test-conv", + "output": { + "messages": "Local.msgs", + }, + "count": 2, + }) + + ctx.state.set( + "System.conversations", + { + "test-conv": { + "id": "test-conv", + "messages": [ + {"role": "user", "content": "msg1"}, + {"role": "assistant", "content": "msg2"}, + {"role": "user", "content": "msg3"}, + {"role": "assistant", "content": "msg4"}, + ], + }, + }, + ) + + handler = get_action_handler("RetrieveConversationMessages") + _events = [e async for e in handler(ctx)] # noqa: F841 + + retrieved = ctx.state.get("Local.msgs") + # Should get last 2 messages + assert len(retrieved) == 2 + assert retrieved[0]["content"] == "msg3" + assert retrieved[1]["content"] == "msg4" + + @pytest.mark.asyncio + async def test_retrieve_conversation_messages_missing_id(self): + """Test RetrieveConversationMessages with missing conversationId.""" + ctx = create_action_context({ + "kind": "RetrieveConversationMessages", + # Missing 'conversationId' + "output": { + "messages": "Local.msgs", + }, + }) + + handler = get_action_handler("RetrieveConversationMessages") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 + + +class TestEvaluateDictValues: + """Tests for _evaluate_dict_values utility function.""" + + def test_evaluate_nested_dict(self): + """Test evaluating expressions in nested dict.""" + from agent_framework_declarative._workflows._actions_basic import _evaluate_dict_values + + state = WorkflowState() + state.set("Local.name", "World") + + data = { + "greeting": "=Local.name", + "nested": { + "value": "=Local.name", + }, + "list": ["=Local.name", "static"], + "number": 42, + } + + result = _evaluate_dict_values(data, state) + + assert result["greeting"] == "World" + assert result["nested"]["value"] == "World" + assert result["list"][0] == "World" + assert result["list"][1] == "static" + assert result["number"] == 42 + + def test_evaluate_list_with_dicts(self): + """Test evaluating expressions in list containing dicts.""" + from agent_framework_declarative._workflows._actions_basic import _evaluate_dict_values + + state = WorkflowState() + state.set("Local.x", 10) + + data = { + "items": [ + {"value": "=Local.x"}, + {"value": "static"}, + ], + } + + result = _evaluate_dict_values(data, state) + + assert result["items"][0]["value"] == 10 + assert result["items"][1]["value"] == "static" + + +class TestNormalizeVariablePath: + """Tests for _normalize_variable_path utility function.""" + + def test_with_known_namespace(self): + """Test that known namespaces are preserved.""" + from agent_framework_declarative._workflows._actions_basic import _normalize_variable_path + + assert _normalize_variable_path("Local.var") == "Local.var" + assert _normalize_variable_path("System.ConversationId") == "System.ConversationId" + assert _normalize_variable_path("Workflow.Outputs.result") == "Workflow.Outputs.result" + assert _normalize_variable_path("Agent.text") == "Agent.text" + assert _normalize_variable_path("Conversation.messages") == "Conversation.messages" + + def test_with_custom_namespace(self): + """Test that custom namespace is preserved.""" + from agent_framework_declarative._workflows._actions_basic import _normalize_variable_path + + assert _normalize_variable_path("Custom.var") == "Custom.var" + + def test_without_namespace(self): + """Test that variables without namespace get Local prefix.""" + from agent_framework_declarative._workflows._actions_basic import _normalize_variable_path + + assert _normalize_variable_path("myVar") == "Local.myVar" + + +class TestInterpolateString: + """Tests for _interpolate_string utility function.""" + + def test_basic_interpolation(self): + """Test basic variable interpolation.""" + from agent_framework_declarative._workflows._actions_basic import _interpolate_string + + state = WorkflowState() + state.set("Local.name", "Alice") + + result = _interpolate_string("Hello {Local.name}!", state) + assert result == "Hello Alice!" + + def test_multiple_variables(self): + """Test interpolating multiple variables.""" + from agent_framework_declarative._workflows._actions_basic import _interpolate_string + + state = WorkflowState() + state.set("Local.first", "John") + state.set("Local.last", "Doe") + + result = _interpolate_string("{Local.first} {Local.last}", state) + assert result == "John Doe" + + def test_missing_variable_becomes_empty(self): + """Test that missing variables become empty strings.""" + from agent_framework_declarative._workflows._actions_basic import _interpolate_string + + state = WorkflowState() + + result = _interpolate_string("Hello {Local.missing}!", state) + assert result == "Hello !" + + def test_no_interpolation_needed(self): + """Test string without variables passes through.""" + from agent_framework_declarative._workflows._actions_basic import _interpolate_string + + state = WorkflowState() + + result = _interpolate_string("Plain text", state) + assert result == "Plain text" diff --git a/python/packages/declarative/tests/test_workflow_state.py b/python/packages/declarative/tests/test_workflow_state.py index 957466806d..854d00ce4b 100644 --- a/python/packages/declarative/tests/test_workflow_state.py +++ b/python/packages/declarative/tests/test_workflow_state.py @@ -223,3 +223,339 @@ def test_reset_local_preserves_other_state(self): assert state.get("Workflow.Inputs.query") == "test" assert state.get("Workflow.Outputs.result") == "done" + + +class TestWorkflowStateEvalSimple: + """Tests for _eval_simple fallback PowerFx evaluation.""" + + def test_negation_prefix(self): + """Test negation with ! prefix.""" + state = WorkflowState() + state.set("Local.value", True) + assert state._eval_simple("!Local.value") is False + state.set("Local.value", False) + assert state._eval_simple("!Local.value") is True + + def test_not_function(self): + """Test Not() function.""" + state = WorkflowState() + state.set("Local.flag", True) + assert state._eval_simple("Not(Local.flag)") is False + state.set("Local.flag", False) + assert state._eval_simple("Not(Local.flag)") is True + + def test_and_operator(self): + """Test And operator.""" + state = WorkflowState() + state.set("Local.a", True) + state.set("Local.b", True) + assert state._eval_simple("Local.a And Local.b") is True + state.set("Local.b", False) + assert state._eval_simple("Local.a And Local.b") is False + + def test_or_operator(self): + """Test Or operator.""" + state = WorkflowState() + state.set("Local.a", False) + state.set("Local.b", False) + assert state._eval_simple("Local.a Or Local.b") is False + state.set("Local.b", True) + assert state._eval_simple("Local.a Or Local.b") is True + + def test_or_operator_double_pipe(self): + """Test || operator.""" + state = WorkflowState() + state.set("Local.x", False) + state.set("Local.y", True) + assert state._eval_simple("Local.x || Local.y") is True + + def test_less_than(self): + """Test < comparison.""" + state = WorkflowState() + state.set("Local.num", 5) + assert state._eval_simple("Local.num < 10") is True + assert state._eval_simple("Local.num < 3") is False + + def test_greater_than(self): + """Test > comparison.""" + state = WorkflowState() + state.set("Local.num", 5) + assert state._eval_simple("Local.num > 3") is True + assert state._eval_simple("Local.num > 10") is False + + def test_less_than_or_equal(self): + """Test <= comparison.""" + state = WorkflowState() + state.set("Local.num", 5) + assert state._eval_simple("Local.num <= 5") is True + assert state._eval_simple("Local.num <= 4") is False + + def test_greater_than_or_equal(self): + """Test >= comparison.""" + state = WorkflowState() + state.set("Local.num", 5) + assert state._eval_simple("Local.num >= 5") is True + assert state._eval_simple("Local.num >= 6") is False + + def test_not_equal(self): + """Test <> comparison.""" + state = WorkflowState() + state.set("Local.val", "hello") + assert state._eval_simple('Local.val <> "world"') is True + assert state._eval_simple('Local.val <> "hello"') is False + + def test_equal(self): + """Test = comparison.""" + state = WorkflowState() + state.set("Local.val", "test") + assert state._eval_simple('Local.val = "test"') is True + assert state._eval_simple('Local.val = "other"') is False + + def test_addition_numeric(self): + """Test + operator with numbers.""" + state = WorkflowState() + state.set("Local.a", 3) + state.set("Local.b", 4) + assert state._eval_simple("Local.a + Local.b") == 7.0 + + def test_addition_string_concat(self): + """Test + operator falls back to string concat.""" + state = WorkflowState() + state.set("Local.a", "hello") + state.set("Local.b", "world") + assert state._eval_simple("Local.a + Local.b") == "helloworld" + + def test_addition_with_none(self): + """Test + treats None as 0.""" + state = WorkflowState() + state.set("Local.a", 5) + # Local.b doesn't exist, so it's None + assert state._eval_simple("Local.a + Local.b") == 5.0 + + def test_subtraction(self): + """Test - operator.""" + state = WorkflowState() + state.set("Local.a", 10) + state.set("Local.b", 3) + assert state._eval_simple("Local.a - Local.b") == 7.0 + + def test_subtraction_with_none(self): + """Test - treats None as 0.""" + state = WorkflowState() + state.set("Local.a", 5) + assert state._eval_simple("Local.a - Local.missing") == 5.0 + + def test_multiplication(self): + """Test * operator.""" + state = WorkflowState() + state.set("Local.a", 4) + state.set("Local.b", 5) + assert state._eval_simple("Local.a * Local.b") == 20.0 + + def test_multiplication_with_none(self): + """Test * treats None as 0.""" + state = WorkflowState() + state.set("Local.a", 5) + assert state._eval_simple("Local.a * Local.missing") == 0.0 + + def test_division(self): + """Test / operator.""" + state = WorkflowState() + state.set("Local.a", 20) + state.set("Local.b", 4) + assert state._eval_simple("Local.a / Local.b") == 5.0 + + def test_division_by_zero(self): + """Test / by zero returns None.""" + state = WorkflowState() + state.set("Local.a", 10) + state.set("Local.b", 0) + assert state._eval_simple("Local.a / Local.b") is None + + def test_string_literal_double_quotes(self): + """Test string literal with double quotes.""" + state = WorkflowState() + assert state._eval_simple('"hello world"') == "hello world" + + def test_string_literal_single_quotes(self): + """Test string literal with single quotes.""" + state = WorkflowState() + assert state._eval_simple("'hello world'") == "hello world" + + def test_integer_literal(self): + """Test integer literal.""" + state = WorkflowState() + assert state._eval_simple("42") == 42 + + def test_float_literal(self): + """Test float literal.""" + state = WorkflowState() + assert state._eval_simple("3.14") == 3.14 + + def test_boolean_true_literal(self): + """Test true literal (case insensitive).""" + state = WorkflowState() + assert state._eval_simple("true") is True + assert state._eval_simple("True") is True + assert state._eval_simple("TRUE") is True + + def test_boolean_false_literal(self): + """Test false literal (case insensitive).""" + state = WorkflowState() + assert state._eval_simple("false") is False + assert state._eval_simple("False") is False + assert state._eval_simple("FALSE") is False + + def test_variable_reference(self): + """Test simple variable reference.""" + state = WorkflowState() + state.set("Local.myvar", "myvalue") + assert state._eval_simple("Local.myvar") == "myvalue" + + def test_unknown_expression_returned_as_is(self): + """Test that unknown expressions are returned as-is.""" + state = WorkflowState() + result = state._eval_simple("unknown_identifier") + assert result == "unknown_identifier" + + def test_system_namespace_reference(self): + """Test System namespace variable reference.""" + state = WorkflowState() + # System is initialized with default values + result = state._eval_simple("System.ConversationId") + assert result == "default" + + def test_agent_namespace_reference(self): + """Test Agent namespace variable reference.""" + state = WorkflowState() + state.set_agent_result(text="agent response") + assert state._eval_simple("Agent.text") == "agent response" + + def test_conversation_namespace_reference(self): + """Test Conversation namespace variable reference.""" + state = WorkflowState() + state.add_conversation_message({"role": "user", "content": "hello"}) + result = state._eval_simple("Conversation.messages") + assert len(result) == 1 + + def test_workflow_inputs_reference(self): + """Test Workflow.Inputs reference.""" + state = WorkflowState(inputs={"name": "test"}) + assert state._eval_simple("Workflow.Inputs.name") == "test" + + +class TestWorkflowStateParseFunctionArgs: + """Tests for _parse_function_args helper.""" + + def test_simple_args(self): + """Test parsing simple comma-separated args.""" + state = WorkflowState() + args = state._parse_function_args("1, 2, 3") + assert args == ["1", "2", "3"] + + def test_string_args_with_commas(self): + """Test parsing string args containing commas.""" + state = WorkflowState() + args = state._parse_function_args('"hello, world", "another"') + assert args == ['"hello, world"', '"another"'] + + def test_nested_function_args(self): + """Test parsing nested function calls.""" + state = WorkflowState() + args = state._parse_function_args("Concat(a, b), c") + assert args == ["Concat(a, b)", "c"] + + def test_empty_args(self): + """Test parsing empty args string.""" + state = WorkflowState() + args = state._parse_function_args("") + assert args == [] + + def test_single_arg(self): + """Test parsing single argument.""" + state = WorkflowState() + args = state._parse_function_args("single") + assert args == ["single"] + + def test_deeply_nested_parens(self): + """Test parsing deeply nested parentheses.""" + state = WorkflowState() + args = state._parse_function_args("Func1(Func2(a, b)), c") + assert args == ["Func1(Func2(a, b))", "c"] + + +class TestWorkflowStateEvalIfExpression: + """Tests for eval_if_expression method.""" + + def test_dict_values_evaluated(self): + """Test that dict values are recursively evaluated.""" + state = WorkflowState() + state.set("Local.name", "World") + result = state.eval_if_expression({"greeting": "=Local.name", "static": "value"}) + assert result == {"greeting": "World", "static": "value"} + + def test_list_values_evaluated(self): + """Test that list values are recursively evaluated.""" + state = WorkflowState() + state.set("Local.val", 42) + result = state.eval_if_expression(["=Local.val", "static"]) + assert result == [42, "static"] + + def test_nested_dict_in_list(self): + """Test nested dict in list is evaluated.""" + state = WorkflowState() + state.set("Local.x", 10) + result = state.eval_if_expression([{"key": "=Local.x"}]) + assert result == [{"key": 10}] + + +class TestWorkflowStateSetErrors: + """Tests for set() error handling.""" + + def test_set_workflow_directly_raises(self): + """Test that setting Workflow directly raises error.""" + state = WorkflowState() + with pytest.raises(ValueError, match="Cannot set 'Workflow' directly"): + state.set("Workflow", "value") + + def test_set_unknown_workflow_namespace_raises(self): + """Test that setting unknown Workflow sub-namespace raises.""" + state = WorkflowState() + with pytest.raises(ValueError, match="Unknown Workflow namespace"): + state.set("Workflow.Unknown.path", "value") + + def test_set_namespace_root_raises(self): + """Test that setting namespace root raises error.""" + state = WorkflowState() + with pytest.raises(ValueError, match="Cannot replace entire namespace"): + state.set("Local", "value") + + +class TestWorkflowStateGetEdgeCases: + """Tests for get() edge cases.""" + + def test_get_empty_path(self): + """Test get with empty path returns default.""" + state = WorkflowState() + assert state.get("", "default") == "default" + + def test_get_unknown_namespace(self): + """Test get from unknown namespace returns default.""" + state = WorkflowState() + assert state.get("Unknown.path") is None + assert state.get("Unknown.path", "fallback") == "fallback" + + def test_get_with_object_attribute(self): + """Test get navigates object attributes.""" + state = WorkflowState() + + class MockObj: + attr = "attribute_value" + + state.set("Local.obj", MockObj()) + assert state.get("Local.obj.attr") == "attribute_value" + + def test_get_unknown_workflow_subspace(self): + """Test get from unknown Workflow sub-namespace.""" + state = WorkflowState() + assert state.get("Workflow.Unknown.path") is None diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 8ca5e0f4bc..029347f295 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -163,11 +163,13 @@ YAML-based declarative workflows allow you to define multi-agent orchestration p | Sample | File | Concepts | |---|---|---| +| Agent to Function Tool | [declarative/agent_to_function_tool/](./declarative/agent_to_function_tool/) | Chain agent output to InvokeFunctionTool actions | | Conditional Workflow | [declarative/conditional_workflow/](./declarative/conditional_workflow/) | Nested conditional branching based on user input | | Customer Support | [declarative/customer_support/](./declarative/customer_support/) | Multi-agent customer support with routing | | Deep Research | [declarative/deep_research/](./declarative/deep_research/) | Research workflow with planning, searching, and synthesis | | Function Tools | [declarative/function_tools/](./declarative/function_tools/) | Invoking Python functions from declarative workflows | | Human-in-Loop | [declarative/human_in_loop/](./declarative/human_in_loop/) | Interactive workflows that request user input | +| Invoke Function Tool | [declarative/invoke_function_tool/](./declarative/invoke_function_tool/) | Call registered Python functions with InvokeFunctionTool | | Marketing | [declarative/marketing/](./declarative/marketing/) | Marketing content generation workflow | | Simple Workflow | [declarative/simple_workflow/](./declarative/simple_workflow/) | Basic workflow with variable setting, conditionals, and loops | | Student Teacher | [declarative/student_teacher/](./declarative/student_teacher/) | Student-teacher interaction pattern | diff --git a/python/samples/getting_started/workflows/declarative/README.md b/python/samples/getting_started/workflows/declarative/README.md index b2ce6de198..c35e579251 100644 --- a/python/samples/getting_started/workflows/declarative/README.md +++ b/python/samples/getting_started/workflows/declarative/README.md @@ -69,6 +69,9 @@ actions: - `InvokeAzureAgent` - Call an Azure AI agent - `InvokePromptAgent` - Call a local prompt agent +### Tool Invocation +- `InvokeFunctionTool` - Call a registered Python function + ### Human-in-Loop - `Question` - Request user input - `WaitForInput` - Pause for external input diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py new file mode 100644 index 0000000000..e38408de2e --- /dev/null +++ b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent to Function Tool workflow sample.""" diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py new file mode 100644 index 0000000000..9c19f9f8be --- /dev/null +++ b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent to Function Tool sample - demonstrates chaining agent output to function tools. + +This sample shows how to: +1. Use InvokeAzureAgent to analyze user input with an AI model +2. Pass the agent's structured output to InvokeFunctionTool actions +3. Chain multiple function tools to process and transform data + +The workflow: +1. Takes a user order request as input +2. Uses an Azure agent to extract structured order data (item, quantity, details) +3. Passes the extracted data to a function tool that calculates the order total +4. Uses another function tool to format the final confirmation message + +Run with: + python -m samples.getting_started.workflows.declarative.agent_to_function_tool.main +""" + +import asyncio +from pathlib import Path +from typing import Any + +from agent_framework import WorkflowOutputEvent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.declarative import WorkflowFactory +from azure.identity import AzureCliCredential +from pydantic import BaseModel, Field + +# Pricing data for the order calculation +ITEM_PRICES = { + "pizza": {"small": 10.99, "medium": 14.99, "large": 18.99, "default": 14.99}, + "burger": {"small": 6.99, "medium": 8.99, "large": 10.99, "default": 8.99}, + "salad": {"small": 7.99, "medium": 9.99, "large": 11.99, "default": 9.99}, + "sandwich": {"small": 6.99, "medium": 8.99, "large": 10.99, "default": 8.99}, + "pasta": {"small": 11.99, "medium": 14.99, "large": 17.99, "default": 14.99}, +} + +EXTRAS_PRICES = { + "extra cheese": 2.00, + "bacon": 2.50, + "avocado": 1.50, + "mushrooms": 1.00, + "pepperoni": 2.00, +} + +# Agent instructions for order analysis +ORDER_ANALYSIS_INSTRUCTIONS = """You are an order analysis assistant. Analyze the customer's order request and extract: +- item: what they want to order (e.g., "pizza", "burger", "salad") +- quantity: how many (as a number, default to 1 if not specified) +- details: any special requests, modifications, or size (e.g., "large", "extra cheese") +- delivery_address: where to deliver (if mentioned, otherwise empty string) + +Always respond with valid JSON matching the required format.""" + + +# Pydantic model for structured agent output +class OrderAnalysis(BaseModel): + """Structured output from the order analysis agent.""" + + item: str = Field(description="The food item being ordered (e.g., pizza, burger)") + quantity: int = Field(description="Number of items ordered", default=1) + details: str = Field(description="Special requests, size, or modifications") + delivery_address: str = Field(description="Delivery address if provided, empty string otherwise", default="") + + +def calculate_order_total(order_data: dict[str, Any]) -> dict[str, Any]: + """Calculate the total cost of an order based on the agent's structured analysis. + + Args: + order_data: Structured dict from the agent containing order analysis. + + Returns: + Dictionary with pricing breakdown. + """ + # Handle case where order_data might be None or invalid + if not order_data or not isinstance(order_data, dict): + return { + "error": f"Invalid order data: {order_data}", + "subtotal": 0.0, + "tax": 0.0, + "delivery_fee": 0.0, + "total": 0.0, + } + + item = str(order_data.get("item", "")).lower() + quantity = int(order_data.get("quantity", 1)) + details = str(order_data.get("details", "")).lower() + has_delivery = bool(order_data.get("delivery_address")) + + # Determine size from details + size = "default" + for s in ["small", "medium", "large"]: + if s in details: + size = s + break + + # Get base price for item + item_key = None + for key in ITEM_PRICES: + if key in item: + item_key = key + break + + unit_price = ITEM_PRICES[item_key].get(size, ITEM_PRICES[item_key]["default"]) if item_key else 12.99 + + # Calculate extras + extras_total = 0.0 + applied_extras: list[dict[str, Any]] = [] + for extra, price in EXTRAS_PRICES.items(): + if extra in details: + extras_total += price * quantity + applied_extras.append({"name": extra, "price": price}) + + # Calculate totals + subtotal = (unit_price * quantity) + extras_total + tax = round(subtotal * 0.08, 2) # 8% tax + delivery_fee = 5.00 if has_delivery else 0.0 + total = round(subtotal + tax + delivery_fee, 2) + + return { + "item": item, + "quantity": quantity, + "size": size if size != "default" else "regular", + "unit_price": unit_price, + "extras": applied_extras, + "extras_total": extras_total, + "subtotal": round(subtotal, 2), + "tax": tax, + "delivery_fee": delivery_fee, + "total": total, + "has_delivery": has_delivery, + } + + +def format_order_confirmation(order_data: dict[str, Any], order_calculation: dict[str, Any]) -> str: + """Format a human-readable order confirmation message. + + Args: + order_data: Structured dict from the agent with order details. + order_calculation: Pricing calculation from calculate_order_total. + + Returns: + Formatted confirmation message. + """ + calc = order_calculation + + # Handle error case + if "error" in calc: + return f"Sorry, we couldn't process your order: {calc['error']}" + + # Build the confirmation message + qty = int(calc.get("quantity", 1)) + size = calc.get("size", "regular").title() + item = calc.get("item", "item").title() + lines = [ + "=" * 50, + "ORDER CONFIRMATION", + "=" * 50, + "", + f"Item: {qty}x {size} {item}", + f"Unit Price: ${calc.get('unit_price', 0):.2f}", + ] + + # Add extras if any + extras = calc.get("extras", []) + if extras: + lines.append("\nExtras:") + for extra in extras: + lines.append(f" + {extra['name'].title()}: ${extra['price']:.2f} each") + lines.append(f" Extras Total: ${calc.get('extras_total', 0):.2f}") + + lines.extend([ + "", + "-" * 30, + f"Subtotal: ${calc.get('subtotal', 0):.2f}", + f"Tax (8%): ${calc.get('tax', 0):.2f}", + ]) + + if calc.get("has_delivery"): + delivery_address = order_data.get("delivery_address", "Address provided") if order_data else "Address provided" + lines.extend([ + f"Delivery Fee: ${calc.get('delivery_fee', 0):.2f}", + f"Delivery To: {delivery_address}", + ]) + + lines.extend([ + "-" * 30, + f"TOTAL: ${calc.get('total', 0):.2f}", + "=" * 50, + "", + "Thank you for your order!", + ]) + + return "\n".join(lines) + + +async def main(): + """Run the agent to function tool workflow.""" + # Create Azure OpenAI client + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create the order analysis agent with structured output + order_analysis_agent = chat_client.as_agent( + name="OrderAnalysisAgent", + instructions=ORDER_ANALYSIS_INSTRUCTIONS, + default_options={"response_format": OrderAnalysis}, + ) + + # Agent registry + agents = { + "OrderAnalysisAgent": order_analysis_agent, + } + + # Get the path to the workflow YAML file + workflow_path = Path(__file__).parent / "workflow.yaml" + + # Create the workflow factory with agents and tools + factory = ( + WorkflowFactory(agents=agents) + .register_tool("calculate_order_total", calculate_order_total) + .register_tool("format_order_confirmation", format_order_confirmation) + ) + + # Create the workflow from the YAML definition + workflow = factory.create_workflow_from_yaml_path(workflow_path) + + print("=" * 60) + print("Agent to Function Tool Workflow Demo") + print("=" * 60) + print() + print("This workflow demonstrates:") + print(" 1. Using InvokeAzureAgent to analyze user input") + print(" 2. Passing agent's structured output to InvokeFunctionTool") + print(" 3. Chaining multiple function tools together") + print() + + # Test with different order inputs + test_queries = [ + "I want to order 3 large pizzas with extra cheese for delivery to 123 Main St", + "2 medium burgers with bacon please", + "Can I get a small salad with avocado and mushrooms, pick up", + ] + + for query in test_queries: + print("-" * 60) + print(f"Input: {query}") + print("-" * 60) + + # Run the workflow with streaming to capture output + try: + async for event in workflow.run_stream(query): + if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, str): + print(event.data, end="", flush=True) + except Exception as e: + print(f"\nWorkflow error: {type(e).__name__}: {e}") + + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/workflow.yaml b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/workflow.yaml new file mode 100644 index 0000000000..92e127bc12 --- /dev/null +++ b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/workflow.yaml @@ -0,0 +1,56 @@ +# Agent to Function Tool Workflow +# +# This workflow demonstrates chaining an agent invocation with a function tool. +# The agent analyzes user input, and the function tool processes the agent's output. +# +# Flow: +# 1. Receive user query +# 2. Invoke an Azure agent to analyze the query and extract structured data +# 3. Pass the agent's structured output to a function tool for processing +# 4. Return the final result +# +# Example input: +# I want to order 3 large pizzas with extra cheese for delivery to 123 Main St + +kind: Workflow +trigger: + + kind: OnConversationStart + id: agent_to_function_tool_demo + actions: + + # Invoke the order analysis agent to extract structured order data + - kind: InvokeAzureAgent + id: analyze_order + agent: + name: OrderAnalysisAgent + input: + messages: =Workflow.Inputs.input + output: + response: Local.agentResponse + responseObject: Local.orderData + + # Invoke a function tool to calculate order total using the agent's output + - kind: InvokeFunctionTool + id: calculate_order + functionName: calculate_order_total + arguments: + order_data: =Local.orderData + output: + result: Local.orderCalculation + + # Invoke another function tool to format the final confirmation + - kind: InvokeFunctionTool + id: format_confirmation + functionName: format_order_confirmation + arguments: + order_data: =Local.orderData + order_calculation: =Local.orderCalculation + output: + result: Local.confirmation + + # Send the final confirmation to the user + - kind: SendActivity + id: send_confirmation + activity: + text: =Local.confirmation diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py b/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py new file mode 100644 index 0000000000..4fa67b28ff --- /dev/null +++ b/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Invoke Function Tool sample workflow. + +This sample demonstrates using the FunctionTool action to invoke +registered Python functions from declarative workflows. +""" diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py b/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py new file mode 100644 index 0000000000..d7335192af --- /dev/null +++ b/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Invoke Function Tool sample - demonstrates FunctionTool workflow actions. + +This sample shows how to: +1. Define Python functions that can be called from workflows +2. Register functions with WorkflowFactory.register_tool() +3. Use the FunctionTool action in YAML to invoke registered functions +4. Pass arguments using expression syntax (=Local.variable) +5. Capture function output in workflow variables + +Run with: + python -m samples.getting_started.workflows.declarative.invoke_function_tool.main +""" + +import asyncio +from pathlib import Path +from typing import Any + +from agent_framework.declarative import WorkflowFactory + + +# Define the function tools that will be registered with the workflow +def get_weather(location: str, unit: str = "F") -> dict[str, Any]: + """Get weather information for a location. + + This is a mock function that returns simulated weather data. + In a real application, this would call a weather API. + + Args: + location: The city or location to get weather for. + unit: Temperature unit ("F" for Fahrenheit, "C" for Celsius). + + Returns: + Dictionary with weather information. + """ + # Simulated weather data + weather_data = { + "Seattle": {"temp": 55, "condition": "rainy"}, + "New York": {"temp": 70, "condition": "partly cloudy"}, + "Los Angeles": {"temp": 85, "condition": "sunny"}, + "Chicago": {"temp": 60, "condition": "windy"}, + } + + data = weather_data.get(location, {"temp": 72, "condition": "unknown"}) + + # Convert to Celsius if requested + temp = data["temp"] + if unit.upper() == "C": + temp = round((temp - 32) * 5 / 9) # type: ignore + + return { + "location": location, + "temp": temp, + "unit": unit.upper(), + "condition": data["condition"], + } + + +def format_message(template: str, data: dict[str, Any]) -> str: + """Format a message template with data. + + Args: + template: A string template with {key} placeholders. + data: Dictionary of values to substitute. + + Returns: + Formatted message string. + """ + try: + return template.format(**data) + except KeyError as e: + return f"Error formatting message: missing key {e}" + + +async def main(): + """Run the invoke function tool workflow.""" + # Get the path to the workflow YAML file + workflow_path = Path(__file__).parent / "workflow.yaml" + + # Create the workflow factory and register our tool functions + factory = ( + WorkflowFactory().register_tool("get_weather", get_weather).register_tool("format_message", format_message) + ) + + # Create the workflow from the YAML definition + workflow = factory.create_workflow_from_yaml_path(workflow_path) + + print("=" * 60) + print("Invoke Function Tool Workflow Demo") + print("=" * 60) + + # Test with different inputs - both location and unit must be provided + # as the workflow expects them in Workflow.Inputs + test_inputs = [ + {"location": "Seattle", "unit": "F"}, + {"location": "New York", "unit": "C"}, + {"location": "Los Angeles", "unit": "F"}, + {"location": "Chicago", "unit": "C"}, + ] + + for inputs in test_inputs: + print(f"\nInput: {inputs}") + print("-" * 40) + + # Run the workflow + events = await workflow.run(inputs) + + # Get the outputs + outputs = events.get_outputs() + for output in outputs: + print(f"Output: {output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml b/python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml new file mode 100644 index 0000000000..bb76d2518a --- /dev/null +++ b/python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml @@ -0,0 +1,50 @@ +# Invoke Function Tool Workflow + +name: invoke_function_tool_demo +description: Demonstrates the InvokeFunctionTool action for invoking registered functions + +actions: + # Set up input location + - kind: SetValue + id: set_location + path: Local.location + value: =If(IsBlank(inputs.location), "Seattle", inputs.location) + + # Set up temperature unit + - kind: SetValue + id: set_unit + path: Local.unit + value: =If(IsBlank(inputs.unit), "F", inputs.unit) + + # Invoke the get_weather function tool + - kind: InvokeFunctionTool + id: invoke_weather + functionName: get_weather + arguments: + location: =Local.location + unit: =Local.unit + output: + messages: Local.weatherToolCallItems + result: Local.weatherInfo + + # Format a human-readable message using another function + - kind: InvokeFunctionTool + id: format_output + functionName: format_message + arguments: + template: "The weather in {location} is {temp}Β°{unit}" + data: =Local.weatherInfo + output: + result: Local.formattedMessage + + # Output the result + - kind: SendActivity + id: send_weather + activity: + text: =Local.formattedMessage + + # Store the result in workflow outputs + - kind: SetValue + id: set_output + path: Workflow.Outputs.weather + value: =Local.weatherInfo From 095eacf90fd71df3373368a9c251a70067335a3e Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 6 Feb 2026 11:25:37 +0900 Subject: [PATCH 2/6] Cleanup --- .../_workflows/_declarative_builder.py | 2 +- .../_workflows/_executors_tools.py | 66 ++++++++++--------- .../_workflows/_factory.py | 14 ++-- .../declarative/tests/test_actions_agents.py | 10 +-- .../invoke_function_tool/__init__.py | 2 +- .../declarative/invoke_function_tool/main.py | 4 +- 6 files changed, 52 insertions(+), 46 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py index a98d5eabf8..ccc09ec623 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py @@ -123,7 +123,7 @@ def __init__( yaml_definition: The parsed YAML workflow definition workflow_id: Optional ID for the workflow (defaults to name from YAML) agents: Registry of agent instances by name (for InvokeAzureAgent actions) - tools: Registry of tool/function instances by name (for FunctionTool actions) + tools: Registry of tool/function instances by name (for InvokeFunctionTool actions) checkpoint_storage: Optional checkpoint storage for pause/resume support validate: Whether to validate the workflow definition before building (default: True) """ diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py index ec56f6df60..a508a36ff3 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py @@ -36,10 +36,12 @@ logger = logging.getLogger(__name__) -# Registry key for function tools in SharedState - reuse existing key for compatibility +# Registry key for function tools in State - reuse existing key so functions registered +# at runtime are discoverable by both agent-based and function-based tool executors. FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY -# State key for storing approval state during yield/resume +# State key prefix for storing approval state during yield/resume. +# The executor's ID is appended to create a per-executor key. TOOL_APPROVAL_STATE_KEY = "_tool_approval_state" @@ -95,7 +97,7 @@ class ToolApprovalResponse: class ToolApprovalState: """State saved during approval yield for resumption. - Stored in SharedState under TOOL_APPROVAL_STATE_KEY when requireApproval=true. + Stored in State under a per-executor key when requireApproval=true. Retrieved by handle_approval_response() to continue execution. """ @@ -162,7 +164,7 @@ class BaseToolExecutor(DeclarativeActionExecutor): """Base class for tool invocation executors. Provides common functionality for all tool-like executors: - - Tool registry lookup (SharedState + WorkflowFactory registration) + - Tool registry lookup (State + WorkflowFactory registration) - Approval flow (request_info pattern with yield/resume) - Output formatting (messages as ChatMessage list + result variable) - Error handling (stores error in output, doesn't raise) @@ -225,14 +227,14 @@ async def _invoke_tool( """ pass - async def _get_tool( + def _get_tool( self, function_name: str, ctx: WorkflowContext[Any, Any], ) -> Any | None: """Get tool from registry. - Checks both WorkflowFactory registry (self._tools) and SharedState registry. + Checks both WorkflowFactory registry (self._tools) and State registry. Args: function_name: Name of the function @@ -246,14 +248,14 @@ async def _get_tool( if tool is not None: return tool - # Check SharedState registry (for runtime registration) + # Check State registry (for runtime registration) try: - tool_registry: dict[str, Any] | None = await ctx.shared_state.get(FUNCTION_TOOL_REGISTRY_KEY) + tool_registry: dict[str, Any] | None = ctx.state.get(FUNCTION_TOOL_REGISTRY_KEY) if tool_registry: return tool_registry.get(function_name) except KeyError: logger.debug( - "%s: tool registry key '%s' not found in shared state " + "%s: tool registry key '%s' not found in state " "(this is normal if tools are only registered via WorkflowFactory)", self.__class__.__name__, FUNCTION_TOOL_REGISTRY_KEY, @@ -280,7 +282,7 @@ def _get_output_config(self) -> tuple[str | None, str | None]: str(result_var) if result_var else None, ) - async def _store_result( + def _store_result( self, result: ToolInvocationResult, state: DeclarativeWorkflowState, @@ -298,13 +300,13 @@ async def _store_result( # Store messages if variable specified if messages_var: path = _normalize_variable_path(messages_var) - await state.set(path, result.messages) + state.set(path, result.messages) # Store result if variable specified if result_var: path = _normalize_variable_path(result_var) if result.rejected: - await state.set( + state.set( path, { "approved": False, @@ -313,9 +315,9 @@ async def _store_result( }, ) elif result.success: - await state.set(path, result.result) + state.set(path, result.result) else: - await state.set( + state.set( path, { "error": result.error, @@ -398,7 +400,7 @@ async def _execute_tool_invocation( ToolInvocationResult with outcome """ # Get tool from registry - tool = await self._get_tool(function_name, ctx) + tool = self._get_tool(function_name, ctx) if tool is None: error_msg = f"Function '{function_name}' not found in registry" logger.error(f"{self.__class__.__name__}: {error_msg}") @@ -452,7 +454,7 @@ async def handle_action( """Handle the tool invocation with optional approval flow. When requireApproval=true: - 1. Saves invocation state to SharedState + 1. Saves invocation state to State (keyed by executor ID) 2. Emits ToolApprovalRequest via ctx.request_info() 3. Workflow yields (returns without ActionComplete) 4. Resumes in handle_approval_response() when user responds @@ -468,16 +470,16 @@ async def handle_action( error_msg = f"Action '{self.id}' is missing required 'functionName' field" logger.error(f"{self.__class__.__name__}: {error_msg}") if result_var: - await state.set(_normalize_variable_path(result_var), {"error": error_msg}) + state.set(_normalize_variable_path(result_var), {"error": error_msg}) await ctx.send_message(ActionComplete()) return - function_name = await state.eval_if_expression(function_name_expr) + function_name = state.eval_if_expression(function_name_expr) if not function_name: error_msg = f"Action '{self.id}': functionName expression evaluated to empty" logger.error(f"{self.__class__.__name__}: {error_msg}") if result_var: - await state.set(_normalize_variable_path(result_var), {"error": error_msg}) + state.set(_normalize_variable_path(result_var), {"error": error_msg}) await ctx.send_message(ActionComplete()) return function_name = str(function_name) @@ -493,20 +495,20 @@ async def handle_action( ) elif isinstance(arguments_def, dict): for key, value in arguments_def.items(): - arguments[key] = await state.eval_if_expression(value) + arguments[key] = state.eval_if_expression(value) # Get conversation ID if specified conversation_id_expr = self._action_def.get("conversationId") conversation_id = None if conversation_id_expr: - evaluated_id = await state.eval_if_expression(conversation_id_expr) + evaluated_id = state.eval_if_expression(conversation_id_expr) conversation_id = str(evaluated_id) if evaluated_id else None # Check if approval is required require_approval = self._action_def.get("requireApproval", False) if require_approval: - # Save state for resumption + # Save state for resumption (keyed by executor ID to avoid collisions) approval_state = ToolApprovalState( function_name=function_name, arguments=arguments, @@ -514,7 +516,8 @@ async def handle_action( output_result_var=result_var, conversation_id=conversation_id, ) - await ctx.shared_state.set(TOOL_APPROVAL_STATE_KEY, approval_state) + approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}" + ctx.state.set(approval_key, approval_state) # Emit approval request - workflow yields here request = ToolApprovalRequest( @@ -536,7 +539,7 @@ async def handle_action( ctx=ctx, ) - await self._store_result(result, state, messages_var, result_var) + self._store_result(result, state, messages_var, result_var) await ctx.send_message(ActionComplete()) @response_handler @@ -551,24 +554,25 @@ async def handle_approval_response( Called when the workflow resumes after yielding for approval. Either executes the tool (if approved) or stores rejection status. """ - state = self._get_state(ctx.shared_state) + state = self._get_state(ctx.state) + approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}" # Retrieve saved invocation state try: - approval_state: ToolApprovalState = await ctx.shared_state.get(TOOL_APPROVAL_STATE_KEY) + approval_state: ToolApprovalState = ctx.state.get(approval_key) except KeyError: error_msg = "Approval state not found, cannot resume tool invocation" logger.error(f"{self.__class__.__name__}: {error_msg}") # Try to store error - get output config from action def as fallback _, result_var = self._get_output_config() if result_var and state: - await state.set(_normalize_variable_path(result_var), {"error": error_msg}) + state.set(_normalize_variable_path(result_var), {"error": error_msg}) await ctx.send_message(ActionComplete()) return # Clean up approval state try: - await ctx.shared_state.delete(TOOL_APPROVAL_STATE_KEY) + ctx.state.delete(approval_key) except KeyError: logger.warning(f"{self.__class__.__name__}: approval state already deleted") @@ -593,7 +597,7 @@ async def handle_approval_response( ) ], ) - await self._store_result(result, state, messages_var, result_var) + self._store_result(result, state, messages_var, result_var) await ctx.send_message(ActionComplete()) return @@ -605,7 +609,7 @@ async def handle_approval_response( ctx=ctx, ) - await self._store_result(result, state, messages_var, result_var) + self._store_result(result, state, messages_var, result_var) await ctx.send_message(ActionComplete()) @@ -639,7 +643,7 @@ class InvokeFunctionToolExecutor(BaseToolExecutor): Tool Registration: Tools can be registered via: 1. WorkflowFactory.register_tool("name", func) - preferred - 2. Setting FUNCTION_TOOL_REGISTRY_KEY in SharedState at runtime + 2. Setting FUNCTION_TOOL_REGISTRY_KEY in State at runtime Examples: .. code-block:: python diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index fefbd8430a..cfa4b68c5e 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -134,7 +134,7 @@ def __init__( self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file) self._agents: dict[str, AgentProtocol | AgentExecutor] = dict(agents) if agents else {} self._bindings: dict[str, Any] = dict(bindings) if bindings else {} - self._tools: dict[str, Any] = {} # Tool registry for FunctionTool actions + self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions self._checkpoint_storage = checkpoint_storage def create_workflow_from_yaml_path( @@ -596,14 +596,14 @@ def send_email(to: str, subject: str, body: str) -> bool: return self def register_tool(self, name: str, func: Any) -> "WorkflowFactory": - """Register a tool function with the factory for use in FunctionTool actions. + """Register a tool function with the factory for use in InvokeFunctionTool actions. - Registered tools are available to FunctionTool actions by name via the functionName field. + Registered tools are available to InvokeFunctionTool actions by name via the functionName field. This method supports fluent chaining. Args: name: The name to register the function under. Must match the functionName - referenced in FunctionTool actions. + referenced in InvokeFunctionTool actions. func: The function to register (can be sync or async). Returns: @@ -624,7 +624,7 @@ async def fetch_data(url: str) -> dict: return {"data": "..."} - # Register functions for use in FunctionTool workflow actions + # Register functions for use in InvokeFunctionTool workflow actions factory = ( WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data) ) @@ -636,7 +636,7 @@ async def fetch_data(url: str) -> dict: .. code-block:: yaml actions: - - kind: FunctionTool + - kind: InvokeFunctionTool id: call_weather functionName: get_weather arguments: @@ -645,6 +645,8 @@ async def fetch_data(url: str) -> dict: output: result: Local.weatherData """ + if not callable(func): + raise TypeError(f"Expected a callable for tool '{name}', got {type(func).__name__}") self._tools[name] = func return self diff --git a/python/packages/declarative/tests/test_actions_agents.py b/python/packages/declarative/tests/test_actions_agents.py index b3b13f3126..5c6d5cc9d1 100644 --- a/python/packages/declarative/tests/test_actions_agents.py +++ b/python/packages/declarative/tests/test_actions_agents.py @@ -403,7 +403,7 @@ async def test_agent_not_found_logs_error(self): @pytest.mark.asyncio async def test_streaming_agent_with_run_stream(self): - """Test invocation of streaming agent with run_stream method.""" + """Test invocation of streaming agent with run(stream=True) method.""" from typing import Any from unittest.mock import MagicMock @@ -424,11 +424,11 @@ async def test_streaming_agent_with_run_stream(self): mock_chunk2.text = " World" mock_chunk2.tool_calls = [] - async def mock_run_stream(messages: list[Any]): + async def mock_run(messages: list[Any], stream: bool = False): yield mock_chunk1 yield mock_chunk2 - mock_agent.run_stream = mock_run_stream + mock_agent.run = mock_run state = WorkflowState() state.set("conversation.messages", [ChatMessage(role="user", text="Test")]) @@ -699,10 +699,10 @@ async def test_streaming_agent(self): mock_chunk = MagicMock() mock_chunk.text = "Chunk" - async def mock_run_stream(messages: list[Any]): + async def mock_run(messages: list[Any], stream: bool = False): yield mock_chunk - mock_agent.run_stream = mock_run_stream + mock_agent.run = mock_run ctx = create_action_context( action={"kind": "InvokePromptAgent", "agent": "testAgent", "outputPath": "Local.result"}, diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py b/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py index 4fa67b28ff..62a7caaae8 100644 --- a/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py +++ b/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py @@ -2,6 +2,6 @@ """Invoke Function Tool sample workflow. -This sample demonstrates using the FunctionTool action to invoke +This sample demonstrates using the InvokeFunctionTool action to invoke registered Python functions from declarative workflows. """ diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py b/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py index d7335192af..a6eb61a0eb 100644 --- a/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py +++ b/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Invoke Function Tool sample - demonstrates FunctionTool workflow actions. +"""Invoke Function Tool sample - demonstrates InvokeFunctionTool workflow actions. This sample shows how to: 1. Define Python functions that can be called from workflows 2. Register functions with WorkflowFactory.register_tool() -3. Use the FunctionTool action in YAML to invoke registered functions +3. Use the InvokeFunctionTool action in YAML to invoke registered functions 4. Pass arguments using expression syntax (=Local.variable) 5. Capture function output in workflow variables From ef7506f143eac3312363d0de1aebd6c151623c78 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 6 Feb 2026 12:13:40 +0900 Subject: [PATCH 3/6] Address PR feedback --- .../_workflows/_factory.py | 2 + .../tests/test_function_tool_executor.py | 650 ++++++++++++++++++ .../tests/test_workflow_factory.py | 14 + .../tests/test_workflow_handlers.py | 56 +- 4 files changed, 694 insertions(+), 28 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index cfa4b68c5e..6c0b4782d5 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -592,6 +592,8 @@ def send_email(to: str, subject: str, body: str) -> bool: workflow = factory.create_workflow_from_yaml_path("workflow.yaml") """ + if not callable(func): + raise TypeError(f"Expected a callable for binding '{name}', got {type(func).__name__}") self._bindings[name] = func return self diff --git a/python/packages/declarative/tests/test_function_tool_executor.py b/python/packages/declarative/tests/test_function_tool_executor.py index 684ca1ff3d..7126e35b34 100644 --- a/python/packages/declarative/tests/test_function_tool_executor.py +++ b/python/packages/declarative/tests/test_function_tool_executor.py @@ -8,11 +8,23 @@ - Output formatting (messages and result) - Error handling (function not found, execution errors) - WorkflowFactory registration +- Approval flow (requireApproval=true with yield/resume) +- Variable path normalization +- Non-callable tool error handling +- JSON serialization fallbacks """ +from typing import Any +from unittest.mock import AsyncMock, MagicMock + import pytest from agent_framework_declarative._workflows import ( + DECLARATIVE_STATE_KEY, + FUNCTION_TOOL_REGISTRY_KEY, + TOOL_APPROVAL_STATE_KEY, + ActionComplete, + ActionTrigger, DeclarativeWorkflowBuilder, InvokeFunctionToolExecutor, ToolApprovalRequest, @@ -21,6 +33,9 @@ ToolInvocationResult, WorkflowFactory, ) +from agent_framework_declarative._workflows._executors_tools import ( + _normalize_variable_path, +) class TestInvokeFunctionToolExecutor: @@ -714,3 +729,638 @@ def dummy() -> str: assert "my_tool" in builder._executors executor = builder._executors["my_tool"] assert isinstance(executor, InvokeFunctionToolExecutor) + + +# ============================================================================ +# Helper: Mock State and Context +# ============================================================================ + + +@pytest.fixture +def mock_state() -> MagicMock: + """Create a mock state with sync get/set/delete methods.""" + mock_state = MagicMock() + mock_state._data = {} + + def mock_get(key: str, default: Any = None) -> Any: + if key not in mock_state._data: + if default is not None: + return default + raise KeyError(key) + return mock_state._data[key] + + def mock_set(key: str, value: Any) -> None: + mock_state._data[key] = value + + def mock_has(key: str) -> bool: + return key in mock_state._data + + def mock_delete(key: str) -> None: + if key in mock_state._data: + del mock_state._data[key] + else: + raise KeyError(key) + + mock_state.get = MagicMock(side_effect=mock_get) + mock_state.set = MagicMock(side_effect=mock_set) + mock_state.has = MagicMock(side_effect=mock_has) + mock_state.delete = MagicMock(side_effect=mock_delete) + + return mock_state + + +@pytest.fixture +def mock_context(mock_state: MagicMock) -> MagicMock: + """Create a mock workflow context.""" + ctx = MagicMock() + ctx.state = mock_state + ctx.send_message = AsyncMock() + ctx.yield_output = AsyncMock() + ctx.request_info = AsyncMock() + return ctx + + +# ============================================================================ +# _normalize_variable_path unit tests (lines 153-155) +# ============================================================================ + + +class TestNormalizeVariablePath: + """Tests for _normalize_variable_path helper.""" + + def test_known_prefix_local(self): + assert _normalize_variable_path("Local.myVar") == "Local.myVar" + + def test_known_prefix_system(self): + assert _normalize_variable_path("System.ConversationId") == "System.ConversationId" + + def test_known_prefix_workflow(self): + assert _normalize_variable_path("Workflow.Inputs.x") == "Workflow.Inputs.x" + + def test_known_prefix_agent(self): + assert _normalize_variable_path("Agent.LastResponse") == "Agent.LastResponse" + + def test_known_prefix_conversation(self): + assert _normalize_variable_path("Conversation.messages") == "Conversation.messages" + + def test_dotted_unknown_prefix(self): + """Dotted path without a known prefix is returned as-is.""" + assert _normalize_variable_path("Custom.myVar") == "Custom.myVar" + + def test_bare_name_gets_local_prefix(self): + """Bare name without any dots defaults to Local. prefix.""" + assert _normalize_variable_path("weatherResult") == "Local.weatherResult" + + def test_bare_name_with_underscore(self): + assert _normalize_variable_path("my_var") == "Local.my_var" + + +# ============================================================================ +# Non-dict output config (line 275) +# ============================================================================ + + +class TestNonDictOutputConfig: + """Tests for non-dict output config handling.""" + + @pytest.mark.asyncio + async def test_output_as_string_is_ignored(self): + """When output is a string instead of dict, both vars should be None.""" + + def noop() -> str: + return "done" + + action_def = { + "kind": "InvokeFunctionTool", + "id": "test_nondictoutput", + "functionName": "noop", + "arguments": {}, + "output": "Local.result", # wrong: should be dict + } + + executor = InvokeFunctionToolExecutor(action_def, tools={"noop": noop}) + messages_var, result_var = executor._get_output_config() + assert messages_var is None + assert result_var is None + + @pytest.mark.asyncio + async def test_output_as_list_is_ignored(self): + """When output is a list instead of dict, both vars should be None.""" + + def noop() -> str: + return "done" + + action_def = { + "kind": "InvokeFunctionTool", + "id": "test_listoutput", + "functionName": "noop", + "arguments": {}, + "output": ["Local.result"], + } + + executor = InvokeFunctionToolExecutor(action_def, tools={"noop": noop}) + messages_var, result_var = executor._get_output_config() + assert messages_var is None + assert result_var is None + + +# ============================================================================ +# Non-callable tool error (line 696) +# ============================================================================ + + +class TestNonCallableTool: + """Tests for non-callable tool invocation.""" + + @pytest.mark.asyncio + async def test_non_callable_stores_error(self): + """Non-callable tool should produce an error result.""" + yaml_def = { + "name": "non_callable_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_noncallable", + "functionName": "not_a_func", + "arguments": {}, + "output": {"result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result.error"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"not_a_func": "i_am_a_string"}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert any("not callable" in out.lower() for out in outputs) + + +# ============================================================================ +# Non-dict arguments warning (line 491) +# ============================================================================ + + +class TestNonDictArguments: + """Tests for non-dict arguments handling.""" + + @pytest.mark.asyncio + async def test_non_dict_arguments_ignored(self): + """When arguments is not a dict, it should be ignored with a warning.""" + + def no_args_needed() -> str: + return "ok" + + yaml_def = { + "name": "nondict_args_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_with_bad_args", + "functionName": "no_args_needed", + "arguments": "invalid_string_args", + "output": {"result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"no_args_needed": no_args_needed}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + assert "ok" in outputs + + +# ============================================================================ +# JSON serialization fallbacks (lines 351-353, 369-371) +# ============================================================================ + + +class TestFormatMessagesSerialization: + """Tests for JSON serialization fallbacks in _format_messages.""" + + @pytest.mark.asyncio + async def test_non_serializable_result_uses_str_fallback(self): + """When the function returns a non-JSON-serializable object, str() is used.""" + + class CustomObj: + def __str__(self): + return "custom_string_repr" + + def returns_custom() -> object: + return CustomObj() + + yaml_def = { + "name": "nonserializable_result_test", + "actions": [ + { + "kind": "InvokeFunctionTool", + "id": "call_custom", + "functionName": "returns_custom", + "arguments": {}, + "output": {"messages": "Local.msgs", "result": "Local.result"}, + }, + {"kind": "SendActivity", "id": "done", "activity": {"text": "Done"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def, tools={"returns_custom": returns_custom}) + workflow = builder.build() + + events = await workflow.run({}) + outputs = events.get_outputs() + + # Should complete without crashing + assert "Done" in outputs + + @pytest.mark.asyncio + async def test_format_messages_directly_with_non_serializable(self): + """Directly test _format_messages with non-serializable arguments and result.""" + + class Unserializable: + def __str__(self): + return "unserializable_obj" + + action_def = { + "kind": "InvokeFunctionTool", + "id": "test_serialize", + "functionName": "dummy", + } + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + # Non-serializable arguments + messages = await executor._format_messages( + function_name="test_func", + arguments={"obj": Unserializable()}, + result=Unserializable(), + ) + + # Should produce 2 messages (tool_call + tool_result) without crashing + assert len(messages) == 2 + assert messages[0].role == "assistant" + assert messages[1].role == "tool" + + +# ============================================================================ +# Approval flow tests (lines 512-532, 557-613) +# ============================================================================ + + +class TestApprovalFlow: + """Tests for the requireApproval=true flow with yield/resume pattern.""" + + def _init_state(self, mock_state: MagicMock) -> None: + """Pre-populate the state with declarative workflow data so _ensure_state_initialized works.""" + from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY + + mock_state._data[DECLARATIVE_STATE_KEY] = { + "Inputs": {}, + "Outputs": {}, + "Local": {}, + "System": { + "ConversationId": "test-conv", + "LastMessage": {"Text": "", "Id": ""}, + "LastMessageText": "", + "LastMessageId": "", + }, + "Agent": {}, + "Conversation": {"messages": [], "history": []}, + } + + @pytest.mark.asyncio + async def test_approval_required_emits_request(self, mock_state, mock_context): + """When requireApproval=true, handle_action should emit ToolApprovalRequest and return.""" + self._init_state(mock_state) + + def my_tool(x: int) -> int: + return x * 2 + + action_def = { + "kind": "InvokeFunctionTool", + "id": "approval_test", + "functionName": "my_tool", + "requireApproval": True, + "arguments": {"x": 5}, + "output": {"result": "Local.result"}, + } + + executor = InvokeFunctionToolExecutor(action_def, tools={"my_tool": my_tool}) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should have called request_info with ToolApprovalRequest + mock_context.request_info.assert_called_once() + request = mock_context.request_info.call_args[0][0] + assert isinstance(request, ToolApprovalRequest) + assert request.function_name == "my_tool" + assert request.arguments == {"x": 5} + + # Should NOT have sent ActionComplete (workflow yields) + mock_context.send_message.assert_not_called() + + # Approval state should be saved in state + approval_key = f"{TOOL_APPROVAL_STATE_KEY}_approval_test" + saved_state = mock_state._data[approval_key] + assert isinstance(saved_state, ToolApprovalState) + assert saved_state.function_name == "my_tool" + assert saved_state.arguments == {"x": 5} + + @pytest.mark.asyncio + async def test_approval_response_approved(self, mock_state, mock_context): + """When approval response is approved, the tool should be invoked.""" + self._init_state(mock_state) + + call_log = [] + + def my_tool(x: int) -> int: + call_log.append(x) + return x * 2 + + action_def = { + "kind": "InvokeFunctionTool", + "id": "approval_approved", + "functionName": "my_tool", + "requireApproval": True, + "arguments": {"x": 7}, + "output": {"result": "Local.result"}, + } + + executor = InvokeFunctionToolExecutor(action_def, tools={"my_tool": my_tool}) + + # Pre-populate approval state (simulating what handle_action stores) + approval_key = f"{TOOL_APPROVAL_STATE_KEY}_approval_approved" + mock_state._data[approval_key] = ToolApprovalState( + function_name="my_tool", + arguments={"x": 7}, + output_messages_var=None, + output_result_var="Local.result", + conversation_id=None, + ) + + # Simulate the response + original_request = ToolApprovalRequest( + request_id="req-123", + function_name="my_tool", + arguments={"x": 7}, + ) + response = ToolApprovalResponse(approved=True) + + await executor.handle_approval_response(original_request, response, mock_context) + + # Tool should have been called + assert call_log == [7] + + # ActionComplete should have been sent + mock_context.send_message.assert_called_once() + sent = mock_context.send_message.call_args[0][0] + assert isinstance(sent, ActionComplete) + + # Approval state should be cleaned up + assert approval_key not in mock_state._data + + @pytest.mark.asyncio + async def test_approval_response_rejected(self, mock_state, mock_context): + """When approval response is rejected, rejection status should be stored.""" + self._init_state(mock_state) + + def my_tool(x: int) -> int: + raise AssertionError("Should not be called when rejected") + + action_def = { + "kind": "InvokeFunctionTool", + "id": "approval_rejected", + "functionName": "my_tool", + "requireApproval": True, + "arguments": {"x": 5}, + "output": {"result": "Local.result"}, + } + + executor = InvokeFunctionToolExecutor(action_def, tools={"my_tool": my_tool}) + + # Pre-populate approval state + approval_key = f"{TOOL_APPROVAL_STATE_KEY}_approval_rejected" + mock_state._data[approval_key] = ToolApprovalState( + function_name="my_tool", + arguments={"x": 5}, + output_messages_var=None, + output_result_var="Local.result", + conversation_id=None, + ) + + original_request = ToolApprovalRequest( + request_id="req-456", + function_name="my_tool", + arguments={"x": 5}, + ) + response = ToolApprovalResponse(approved=False, reason="Not authorized") + + await executor.handle_approval_response(original_request, response, mock_context) + + # ActionComplete should have been sent + mock_context.send_message.assert_called_once() + + # Result var should contain rejection info + state_data = mock_state._data.get(DECLARATIVE_STATE_KEY, {}) + local_data = state_data.get("Local", {}) + result = local_data.get("result") + assert result is not None + assert result["rejected"] is True + assert result["reason"] == "Not authorized" + assert result["approved"] is False + + @pytest.mark.asyncio + async def test_approval_response_missing_state(self, mock_state, mock_context): + """When approval state is missing on resume, should log error and complete.""" + self._init_state(mock_state) + + action_def = { + "kind": "InvokeFunctionTool", + "id": "missing_state_test", + "functionName": "my_tool", + "requireApproval": True, + "output": {"result": "Local.result"}, + } + + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + # Don't populate approval state - simulate missing state + original_request = ToolApprovalRequest( + request_id="req-789", + function_name="my_tool", + arguments={}, + ) + response = ToolApprovalResponse(approved=True) + + await executor.handle_approval_response(original_request, response, mock_context) + + # Should still send ActionComplete + mock_context.send_message.assert_called_once() + sent = mock_context.send_message.call_args[0][0] + assert isinstance(sent, ActionComplete) + + +# ============================================================================ +# State registry tool lookup (lines 255-257) +# ============================================================================ + + +class TestStateRegistryLookup: + """Tests for tool lookup from State registry.""" + + @pytest.mark.asyncio + async def test_tool_found_in_state_registry(self, mock_state, mock_context): + """Tool should be found from State registry when not in constructor tools.""" + self._init_state(mock_state) + + def state_registered_tool() -> str: + return "from_state" + + # Register tool in State registry + mock_state._data[FUNCTION_TOOL_REGISTRY_KEY] = {"state_tool": state_registered_tool} + + action_def = { + "kind": "InvokeFunctionTool", + "id": "state_lookup", + "functionName": "state_tool", + "arguments": {}, + "output": {"result": "Local.result"}, + } + + # Empty constructor tools - should fall back to State registry + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + tool = executor._get_tool("state_tool", mock_context) + assert tool is state_registered_tool + + def _init_state(self, mock_state: MagicMock) -> None: + from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY + + mock_state._data[DECLARATIVE_STATE_KEY] = { + "Inputs": {}, + "Outputs": {}, + "Local": {}, + "System": { + "ConversationId": "test-conv", + "LastMessage": {"Text": "", "Id": ""}, + "LastMessageText": "", + "LastMessageId": "", + }, + "Agent": {}, + "Conversation": {"messages": [], "history": []}, + } + + @pytest.mark.asyncio + async def test_tool_not_found_in_state_registry_key_error(self, mock_state, mock_context): + """When State registry key doesn't exist, should return None.""" + # Don't populate FUNCTION_TOOL_REGISTRY_KEY - will raise KeyError + + action_def = { + "kind": "InvokeFunctionTool", + "id": "missing_registry", + "functionName": "missing", + } + + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + tool = executor._get_tool("missing", mock_context) + assert tool is None + + @pytest.mark.asyncio + async def test_tool_not_in_registry_returns_none(self, mock_state, mock_context): + """When State registry exists but tool isn't in it, should return None.""" + mock_state._data[FUNCTION_TOOL_REGISTRY_KEY] = {"other_tool": lambda: None} + + action_def = { + "kind": "InvokeFunctionTool", + "id": "wrong_name", + "functionName": "missing", + } + + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + tool = executor._get_tool("missing", mock_context) + assert tool is None + + +# ============================================================================ +# Missing/empty functionName at runtime (lines 470-475, 482) +# ============================================================================ + + +class TestMissingFunctionNameRuntime: + """Tests for missing/empty functionName at runtime with result_var.""" + + def _init_state(self, mock_state: MagicMock) -> None: + from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY + + mock_state._data[DECLARATIVE_STATE_KEY] = { + "Inputs": {}, + "Outputs": {}, + "Local": {}, + "System": { + "ConversationId": "test-conv", + "LastMessage": {"Text": "", "Id": ""}, + "LastMessageText": "", + "LastMessageId": "", + }, + "Agent": {}, + "Conversation": {"messages": [], "history": []}, + } + + @pytest.mark.asyncio + async def test_missing_function_name_stores_error_in_result_var(self, mock_state, mock_context): + """Missing functionName should store error in result_var and complete.""" + self._init_state(mock_state) + + action_def = { + "kind": "InvokeFunctionTool", + "id": "no_name", + # No functionName field + "output": {"result": "Local.errorResult"}, + } + + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should send ActionComplete + mock_context.send_message.assert_called_once() + sent = mock_context.send_message.call_args[0][0] + assert isinstance(sent, ActionComplete) + + # Error should be stored in result_var + state_data = mock_state._data.get("_declarative_workflow_state", {}) + local_data = state_data.get("Local", {}) + assert "error" in local_data.get("errorResult", {}) + + @pytest.mark.asyncio + async def test_empty_function_name_with_result_var(self, mock_state, mock_context): + """Empty functionName expression should store error in result_var.""" + self._init_state(mock_state) + + # Pre-set an empty value for toolName + mock_state._data["_declarative_workflow_state"]["Local"]["toolName"] = "" + + action_def = { + "kind": "InvokeFunctionTool", + "id": "empty_name", + "functionName": "=Local.toolName", + "output": {"result": "Local.errorResult"}, + } + + executor = InvokeFunctionToolExecutor(action_def, tools={}) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should send ActionComplete + mock_context.send_message.assert_called_once() + + # Error should be stored in result_var + state_data = mock_state._data.get("_declarative_workflow_state", {}) + local_data = state_data.get("Local", {}) + assert "error" in local_data.get("errorResult", {}) diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index 99b1dcbeaa..c3471d2b0d 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -312,6 +312,20 @@ def multiply(a: int, b: int) -> int: assert factory._tools["add"](2, 3) == 5 assert factory._tools["multiply"](2, 3) == 6 + def test_register_tool_non_callable_raises(self): + """Test that register_tool raises TypeError for non-callable.""" + factory = WorkflowFactory() + + with pytest.raises(TypeError, match="Expected a callable for tool"): + factory.register_tool("bad_tool", "not_a_function") + + def test_register_binding_non_callable_raises(self): + """Test that register_binding raises TypeError for non-callable.""" + factory = WorkflowFactory() + + with pytest.raises(TypeError, match="Expected a callable for binding"): + factory.register_binding("bad_binding", 42) + class TestWorkflowFactoryEdgeCases: """Tests for edge cases in workflow factory.""" diff --git a/python/packages/declarative/tests/test_workflow_handlers.py b/python/packages/declarative/tests/test_workflow_handlers.py index 6d04df1000..40c3448034 100644 --- a/python/packages/declarative/tests/test_workflow_handlers.py +++ b/python/packages/declarative/tests/test_workflow_handlers.py @@ -123,7 +123,7 @@ async def test_set_value_from_input(self): ) handler = get_action_handler("SetValue") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.copy") == "literal" @@ -141,7 +141,7 @@ async def test_append_to_new_list(self): }) handler = get_action_handler("AppendValue") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.results") == ["item1"] @@ -156,7 +156,7 @@ async def test_append_to_existing_list(self): ctx.state.set("Local.results", ["item1"]) handler = get_action_handler("AppendValue") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.results") == ["item1", "item2"] @@ -225,7 +225,7 @@ async def test_foreach_basic_iteration(self): }) handler = get_action_handler("Foreach") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.results") == ["processed", "processed", "processed"] @@ -242,7 +242,7 @@ async def test_foreach_sets_item_and_index(self): # We'll check the last values after iteration handler = get_action_handler("Foreach") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] # After iteration, the last item/index should be set assert ctx.state.get("Local.item") == "y" @@ -267,7 +267,7 @@ async def test_if_true_branch(self): }) handler = get_action_handler("If") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.branch") == "then" @@ -286,7 +286,7 @@ async def test_if_false_branch(self): }) handler = get_action_handler("If") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.branch") == "else" @@ -314,7 +314,7 @@ async def test_switch_matching_case(self): }) handler = get_action_handler("Switch") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.result") == "two" @@ -334,7 +334,7 @@ async def test_switch_default_case(self): }) handler = get_action_handler("Switch") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.result") == "default" @@ -357,7 +357,7 @@ async def test_repeat_until_condition_met(self): ctx.state.set("Local.count", 0) handler = get_action_handler("RepeatUntil") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] # With condition=False (literal), it will run maxIterations times assert ctx.state.get("Local.iteration") == 3 @@ -380,7 +380,7 @@ async def test_try_without_error(self): }) handler = get_action_handler("TryCatch") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.result") == "success" @@ -398,7 +398,7 @@ async def test_try_with_throw_exception(self): }) handler = get_action_handler("TryCatch") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.result") == "caught" assert ctx.state.get("Local.error.message") == "Test error" @@ -418,7 +418,7 @@ async def test_finally_always_executes(self): }) handler = get_action_handler("TryCatch") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.try") == "ran" assert ctx.state.get("Local.finally") == "ran" @@ -485,7 +485,7 @@ async def test_set_variable_adds_local_prefix(self): }) handler = get_action_handler("SetVariable") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] # Should be stored under Local namespace assert ctx.state.get("Local.myVar") == "test" @@ -608,7 +608,7 @@ async def test_set_text_variable_with_interpolation(self): ctx.state.set("Local.name", "World") handler = get_action_handler("SetTextVariable") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.message") == "Hello World!" @@ -636,7 +636,7 @@ async def test_set_text_variable_non_string_value(self): }) handler = get_action_handler("SetTextVariable") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.num") == 42 @@ -657,7 +657,7 @@ async def test_set_multiple_variables_basic(self): }) handler = get_action_handler("SetMultipleVariables") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.var1") == "one" assert ctx.state.get("Local.var2") == "two" @@ -676,7 +676,7 @@ async def test_set_multiple_variables_missing_variable_skips(self): }) handler = get_action_handler("SetMultipleVariables") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.valid") == "ok" assert ctx.state.get("Local.also_valid") == "also ok" @@ -692,7 +692,7 @@ async def test_set_multiple_variables_uses_path_field(self): }) handler = get_action_handler("SetMultipleVariables") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] # Note: The handler uses 'variable' field, not 'path' # So this won't set anything unless the handler supports both @@ -711,7 +711,7 @@ async def test_reset_variable_basic(self): ctx.state.set("Local.toReset", "some value") handler = get_action_handler("ResetVariable") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.toReset") is None @@ -742,7 +742,7 @@ async def test_clear_all_variables(self): ctx.state.set("Local.var2", "value2") handler = get_action_handler("ClearAllVariables") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] assert ctx.state.get("Local.var1") is None assert ctx.state.get("Local.var2") is None @@ -760,7 +760,7 @@ async def test_create_conversation_basic(self): }) handler = get_action_handler("CreateConversation") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] # Should have generated and stored a conversation ID conv_id = ctx.state.get("Local.convId") @@ -778,7 +778,7 @@ async def test_create_conversation_without_output_var(self): }) handler = get_action_handler("CreateConversation") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] # Should still create conversation in System.conversations conversations = ctx.state.get("System.conversations") @@ -802,7 +802,7 @@ async def test_add_conversation_message(self): }) handler = get_action_handler("AddConversationMessage") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] conversations = ctx.state.get("System.conversations") assert conversations is not None @@ -857,7 +857,7 @@ async def test_copy_conversation_messages(self): ) handler = get_action_handler("CopyConversationMessages") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] conversations = ctx.state.get("System.conversations") target_messages = conversations["target-conv"]["messages"] @@ -890,7 +890,7 @@ async def test_copy_conversation_messages_with_count(self): ) handler = get_action_handler("CopyConversationMessages") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] conversations = ctx.state.get("System.conversations") target_messages = conversations["target-conv"]["messages"] @@ -940,7 +940,7 @@ async def test_retrieve_conversation_messages(self): ) handler = get_action_handler("RetrieveConversationMessages") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] retrieved = ctx.state.get("Local.retrievedMessages") assert len(retrieved) == 2 @@ -974,7 +974,7 @@ async def test_retrieve_conversation_messages_with_count(self): ) handler = get_action_handler("RetrieveConversationMessages") - _events = [e async for e in handler(ctx)] # noqa: F841 + [e async for e in handler(ctx)] retrieved = ctx.state.get("Local.msgs") # Should get last 2 messages From 2bb77accb8407b6b6ff2b5460a5b7f72682c0c17 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 9 Feb 2026 13:55:51 +0900 Subject: [PATCH 4/6] Remove InvokeTool kind, consolidate to InvokeFunctionTool --- .../_workflows/__init__.py | 2 - .../_workflows/_executors_agents.py | 68 ------------ .../declarative/tests/test_graph_coverage.py | 104 ------------------ .../agent_to_function_tool/main.py | 5 +- 4 files changed, 2 insertions(+), 177 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py b/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py index aa4ccfd1f0..a5da22751e 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py @@ -35,7 +35,6 @@ AgentResult, ExternalLoopState, InvokeAzureAgentExecutor, - InvokeToolExecutor, ) from ._executors_basic import ( BASIC_ACTION_EXECUTORS, @@ -132,7 +131,6 @@ "ForeachNextExecutor", "InvokeAzureAgentExecutor", "InvokeFunctionToolExecutor", - "InvokeToolExecutor", "JoinExecutor", "LoopControl", "LoopIterationResult", diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index d4300a9909..d85c42b28a 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -1007,75 +1007,7 @@ async def handle_external_input_response( await ctx.send_message(ActionComplete()) -class InvokeToolExecutor(DeclarativeActionExecutor): - """Executor that invokes a registered tool/function. - - Tools are simpler than agents - they take input, perform an action, - and return a result synchronously (or with a simple async call). - """ - - @handler - async def handle_action( - self, - trigger: Any, - ctx: WorkflowContext[ActionComplete], - ) -> None: - """Handle the tool invocation.""" - state = await self._ensure_state_initialized(ctx, trigger) - - tool_name = self._action_def.get("tool") or self._action_def.get("toolName", "") - input_expr = self._action_def.get("input") - output_property = self._action_def.get("output", {}).get("property") or self._action_def.get("resultProperty") - parameters = self._action_def.get("parameters", {}) - - # Get tools registry - try: - tool_registry: dict[str, Any] | None = ctx.state.get(TOOL_REGISTRY_KEY) - except KeyError: - tool_registry = {} - - tool: Any = tool_registry.get(tool_name) if tool_registry else None - - if tool is None: - error_msg = f"Tool '{tool_name}' not found in registry" - if output_property: - 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] = state.eval_if_expression(param_expression) - - # Add main input if specified - if input_expr: - params["input"] = state.eval_if_expression(input_expr) - - try: - # Invoke the tool - if callable(tool): - from inspect import isawaitable - - result = tool(**params) - if isawaitable(result): - result = await result - - # Store result - if output_property: - state.set(output_property, result) - - except Exception as e: - if output_property: - state.set(output_property, {"error": str(e)}) - await ctx.send_message(ActionComplete()) - return - - await ctx.send_message(ActionComplete()) - - # Mapping of agent action kinds to executor classes AGENT_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = { "InvokeAzureAgent": InvokeAzureAgentExecutor, - "InvokeTool": InvokeToolExecutor, } diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index fd01faf2a4..4532a9697a 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -1036,83 +1036,6 @@ class MockResult: parsed = state.get("Local.Parsed") assert parsed == {"status": "ok", "count": 42} - async def test_invoke_tool_executor_not_found(self, mock_context, mock_state): - """Test InvokeToolExecutor when tool not found.""" - from agent_framework_declarative._workflows._executors_agents import ( - InvokeToolExecutor, - ) - - state = DeclarativeWorkflowState(mock_state) - state.initialize() - - action_def = { - "kind": "InvokeTool", - "tool": "MissingTool", - "resultProperty": "Local.result", - } - executor = InvokeToolExecutor(action_def) - - await executor.handle_action(ActionTrigger(), mock_context) - - result = state.get("Local.result") - assert result == {"error": "Tool 'MissingTool' not found in registry"} - - async def test_invoke_tool_executor_sync_tool(self, mock_context, mock_state): - """Test InvokeToolExecutor with synchronous tool.""" - from agent_framework_declarative._workflows._executors_agents import ( - TOOL_REGISTRY_KEY, - InvokeToolExecutor, - ) - - def my_tool(x: int, y: int) -> int: - return x + y - - mock_state._data[TOOL_REGISTRY_KEY] = {"add": my_tool} - - state = DeclarativeWorkflowState(mock_state) - state.initialize() - - action_def = { - "kind": "InvokeTool", - "tool": "add", - "parameters": {"x": 5, "y": 3}, - "resultProperty": "Local.result", - } - executor = InvokeToolExecutor(action_def) - - await executor.handle_action(ActionTrigger(), mock_context) - - result = state.get("Local.result") - assert result == 8 - - async def test_invoke_tool_executor_async_tool(self, mock_context, mock_state): - """Test InvokeToolExecutor with asynchronous tool.""" - from agent_framework_declarative._workflows._executors_agents import ( - TOOL_REGISTRY_KEY, - InvokeToolExecutor, - ) - - async def my_async_tool(input: str) -> str: - return f"Processed: {input}" - - mock_state._data[TOOL_REGISTRY_KEY] = {"process": my_async_tool} - - state = DeclarativeWorkflowState(mock_state) - state.initialize() - - action_def = { - "kind": "InvokeTool", - "tool": "process", - "input": "test data", - "resultProperty": "Local.result", - } - executor = InvokeToolExecutor(action_def) - - await executor.handle_action(ActionTrigger(), mock_context) - - result = state.get("Local.result") - assert result == "Processed: test data" - # --------------------------------------------------------------------------- # Control Flow Executors Tests - Additional coverage @@ -1911,33 +1834,6 @@ async def test_agent_executor_string_result(self, mock_context, mock_state): result = state.get("Local.result") assert result == "Direct string response" - async def test_invoke_tool_with_error(self, mock_context, mock_state): - """Test InvokeToolExecutor handles tool errors.""" - from agent_framework_declarative._workflows._executors_agents import ( - TOOL_REGISTRY_KEY, - InvokeToolExecutor, - ) - - def failing_tool(**kwargs): - raise ValueError("Tool error") - - mock_state._data[TOOL_REGISTRY_KEY] = {"bad_tool": failing_tool} - - state = DeclarativeWorkflowState(mock_state) - state.initialize() - - action_def = { - "kind": "InvokeTool", - "tool": "bad_tool", - "resultProperty": "Local.result", - } - executor = InvokeToolExecutor(action_def) - - await executor.handle_action(ActionTrigger(), mock_context) - - result = state.get("Local.result") - assert result == {"error": "Tool error"} - # --------------------------------------------------------------------------- # PowerFx Functions Coverage diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py index 9c19f9f8be..e43e79b6ac 100644 --- a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py +++ b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py @@ -21,7 +21,6 @@ from pathlib import Path from typing import Any -from agent_framework import WorkflowOutputEvent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.declarative import WorkflowFactory from azure.identity import AzureCliCredential @@ -249,8 +248,8 @@ async def main(): # Run the workflow with streaming to capture output try: - async for event in workflow.run_stream(query): - if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, str): + async for event in workflow.run(query, stream=True): + if event.type == "output" and isinstance(event.data, str): print(event.data, end="", flush=True) except Exception as e: print(f"\nWorkflow error: {type(e).__name__}: {e}") From 4b003bb8984da41aedec0bb93bd0ca847ae3afc9 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 24 Feb 2026 15:59:33 +0900 Subject: [PATCH 5/6] Fix sample locations --- .../_workflows/_executors_tools.py | 35 +++++++++---------- .../_workflows/_factory.py | 4 +-- .../tests/test_function_tool_executor.py | 29 ++++++++------- .../agent_to_function_tool/main.py | 2 +- .../agent_to_function_tool/workflow.yaml | 0 .../declarative/invoke_function_tool/main.py | 2 +- .../invoke_function_tool/workflow.yaml | 1 + .../agent_to_function_tool/__init__.py | 3 -- .../invoke_function_tool/__init__.py | 7 ---- 9 files changed, 35 insertions(+), 48 deletions(-) rename python/samples/{getting_started/workflows => 03-workflows}/declarative/agent_to_function_tool/main.py (99%) rename python/samples/{getting_started/workflows => 03-workflows}/declarative/agent_to_function_tool/workflow.yaml (100%) rename python/samples/{getting_started/workflows => 03-workflows}/declarative/invoke_function_tool/main.py (97%) rename python/samples/{getting_started/workflows => 03-workflows}/declarative/invoke_function_tool/workflow.yaml (98%) delete mode 100644 python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py delete mode 100644 python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py index fe663469ad..829d48103f 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py @@ -64,13 +64,11 @@ class ToolApprovalRequest: request_id: Unique identifier for this approval request. function_name: Evaluated function name to be invoked. arguments: Evaluated arguments to be passed to the function. - conversation_id: Optional conversation ID for context. """ request_id: str function_name: str arguments: dict[str, Any] - conversation_id: str | None = None @dataclass @@ -105,7 +103,7 @@ class ToolApprovalState: arguments: dict[str, Any] output_messages_var: str | None output_result_var: str | None - conversation_id: str | None + auto_send: bool # ============================================================================ @@ -175,7 +173,6 @@ class BaseToolExecutor(DeclarativeActionExecutor): YAML Schema (common fields): kind: id: unique_id - conversationId: =System.ConversationId # optional functionName: function_to_call # required, supports =expression syntax requireApproval: true # optional, default=false arguments: # optional dictionary @@ -184,6 +181,7 @@ class BaseToolExecutor(DeclarativeActionExecutor): output: messages: Local.toolCallMessages # Message list result: Local.toolResult + autoSend: true # optional, default=true """ def __init__( @@ -263,23 +261,25 @@ def _get_tool( return None - def _get_output_config(self) -> tuple[str | None, str | None]: + def _get_output_config(self) -> tuple[str | None, str | None, bool]: """Parse output configuration from action definition. Returns: - Tuple of (messages_var, result_var) + Tuple of (messages_var, result_var, auto_send) """ output_config = self._action_def.get("output", {}) if not isinstance(output_config, dict): - return None, None + return None, None, True messages_var = output_config.get("messages") result_var = output_config.get("result") + auto_send = bool(output_config.get("autoSend", True)) return ( str(messages_var) if messages_var else None, str(result_var) if result_var else None, + auto_send, ) def _store_result( @@ -462,7 +462,7 @@ async def handle_action( state = await self._ensure_state_initialized(ctx, trigger) # Parse output configuration early so we can store errors - messages_var, result_var = self._get_output_config() + messages_var, result_var, auto_send = self._get_output_config() # Get and evaluate function name (required) function_name_expr = self._action_def.get("functionName") @@ -497,13 +497,6 @@ async def handle_action( for key, value in arguments_def.items(): arguments[key] = state.eval_if_expression(value) - # Get conversation ID if specified - conversation_id_expr = self._action_def.get("conversationId") - conversation_id = None - if conversation_id_expr: - evaluated_id = state.eval_if_expression(conversation_id_expr) - conversation_id = str(evaluated_id) if evaluated_id else None - # Check if approval is required require_approval = self._action_def.get("requireApproval", False) @@ -514,7 +507,7 @@ async def handle_action( arguments=arguments, output_messages_var=messages_var, output_result_var=result_var, - conversation_id=conversation_id, + auto_send=auto_send, ) approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}" ctx.state.set(approval_key, approval_state) @@ -524,7 +517,6 @@ async def handle_action( request_id=str(uuid.uuid4()), function_name=function_name, arguments=arguments, - conversation_id=conversation_id, ) logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'") await ctx.request_info(request, ToolApprovalResponse) @@ -540,6 +532,8 @@ async def handle_action( ) self._store_result(result, state, messages_var, result_var) + if auto_send and result.success and result.result is not None: + await ctx.yield_output(str(result.result)) await ctx.send_message(ActionComplete()) @response_handler @@ -564,7 +558,7 @@ async def handle_approval_response( error_msg = "Approval state not found, cannot resume tool invocation" logger.error(f"{self.__class__.__name__}: {error_msg}") # Try to store error - get output config from action def as fallback - _, result_var = self._get_output_config() + _, result_var, _ = self._get_output_config() if result_var and state: state.set(_normalize_variable_path(result_var), {"error": error_msg}) await ctx.send_message(ActionComplete()) @@ -580,6 +574,7 @@ async def handle_approval_response( arguments = approval_state.arguments messages_var = approval_state.output_messages_var result_var = approval_state.output_result_var + auto_send = approval_state.auto_send # Check if approved if not response.approved: @@ -610,6 +605,8 @@ async def handle_approval_response( ) self._store_result(result, state, messages_var, result_var) + if auto_send and result.success and result.result is not None: + await ctx.yield_output(str(result.result)) await ctx.send_message(ActionComplete()) @@ -630,7 +627,6 @@ class InvokeFunctionToolExecutor(BaseToolExecutor): YAML Schema: kind: InvokeFunctionTool id: invoke_function_example - conversationId: =System.ConversationId # optional functionName: get_weather # required, supports =expression syntax requireApproval: true # optional, default=false arguments: # optional dictionary @@ -639,6 +635,7 @@ class InvokeFunctionToolExecutor(BaseToolExecutor): output: messages: Local.weatherToolCallItems # Message list result: Local.WeatherInfo + autoSend: true # optional, default=true Tool Registration: Tools can be registered via: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index 37f89c406c..4d6f3fa35b 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -607,9 +607,9 @@ def send_email(to: str, subject: str, body: str) -> bool: return self def register_tool(self, name: str, func: Any) -> WorkflowFactory: - """Register a tool function with the factory for use in InvokeFunctionTool actions. + """Register a function with the factory for use in InvokeFunctionTool actions. - Registered tools are available to InvokeFunctionTool actions by name via the functionName field. + Registered functions are available to InvokeFunctionTool actions by name via the functionName field. This method supports fluent chaining. Args: diff --git a/python/packages/declarative/tests/test_function_tool_executor.py b/python/packages/declarative/tests/test_function_tool_executor.py index 9d191c543e..f11b356865 100644 --- a/python/packages/declarative/tests/test_function_tool_executor.py +++ b/python/packages/declarative/tests/test_function_tool_executor.py @@ -376,12 +376,10 @@ def test_approval_request(self): request_id="test-123", function_name="dangerous_operation", arguments={"target": "production"}, - conversation_id="conv-456", ) assert request.request_id == "test-123" assert request.function_name == "dangerous_operation" assert request.arguments == {"target": "production"} - assert request.conversation_id == "conv-456" def test_approval_response_approved(self): """Test creating an approved response.""" @@ -402,13 +400,13 @@ def test_approval_state(self): arguments={"user_id": "123"}, output_messages_var="Local.messages", output_result_var="Local.result", - conversation_id="conv-789", + auto_send=True, ) assert state.function_name == "delete_user" assert state.arguments == {"user_id": "123"} assert state.output_messages_var == "Local.messages" assert state.output_result_var == "Local.result" - assert state.conversation_id == "conv-789" + assert state.auto_send is True class TestInvokeFunctionToolEdgeCases: @@ -589,23 +587,21 @@ def sum_list(numbers: list) -> int: assert any("15" in out for out in outputs) @pytest.mark.asyncio - async def test_conversation_id_expression(self): - """Test conversationId field with expression.""" + async def test_auto_send_disabled(self): + """Test autoSend=false prevents automatic output yielding.""" def echo_id(msg: str) -> str: return msg yaml_def = { - "name": "conversation_id_test", + "name": "auto_send_disabled_test", "actions": [ - {"kind": "SetValue", "id": "set_conv_id", "path": "Local.convId", "value": "conv-123"}, { "kind": "InvokeFunctionTool", - "id": "call_with_conv_id", + "id": "call_no_auto_send", "functionName": "echo_id", - "conversationId": "=Local.convId", "arguments": {"msg": "hello"}, - "output": {"result": "Local.result"}, + "output": {"result": "Local.result", "autoSend": False}, }, {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result"}}, ], @@ -617,6 +613,7 @@ def echo_id(msg: str) -> str: events = await workflow.run({}) outputs = events.get_outputs() + # Result should still be available via explicit SendActivity assert "hello" in outputs @pytest.mark.asyncio @@ -852,9 +849,10 @@ def noop() -> str: } executor = InvokeFunctionToolExecutor(action_def, tools={"noop": noop}) - messages_var, result_var = executor._get_output_config() + messages_var, result_var, auto_send = executor._get_output_config() assert messages_var is None assert result_var is None + assert auto_send is True @pytest.mark.asyncio async def test_output_as_list_is_ignored(self): @@ -872,9 +870,10 @@ def noop() -> str: } executor = InvokeFunctionToolExecutor(action_def, tools={"noop": noop}) - messages_var, result_var = executor._get_output_config() + messages_var, result_var, auto_send = executor._get_output_config() assert messages_var is None assert result_var is None + assert auto_send is True # ============================================================================ @@ -1112,7 +1111,7 @@ def my_tool(x: int) -> int: arguments={"x": 7}, output_messages_var=None, output_result_var="Local.result", - conversation_id=None, + auto_send=True, ) # Simulate the response @@ -1162,7 +1161,7 @@ def my_tool(x: int) -> int: arguments={"x": 5}, output_messages_var=None, output_result_var="Local.result", - conversation_id=None, + auto_send=True, ) original_request = ToolApprovalRequest( diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py b/python/samples/03-workflows/declarative/agent_to_function_tool/main.py similarity index 99% rename from python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py rename to python/samples/03-workflows/declarative/agent_to_function_tool/main.py index e43e79b6ac..55ba77c073 100644 --- a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/main.py +++ b/python/samples/03-workflows/declarative/agent_to_function_tool/main.py @@ -14,7 +14,7 @@ 4. Uses another function tool to format the final confirmation message Run with: - python -m samples.getting_started.workflows.declarative.agent_to_function_tool.main + python -m samples.03-workflows.declarative.agent_to_function_tool.main """ import asyncio diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/workflow.yaml b/python/samples/03-workflows/declarative/agent_to_function_tool/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/agent_to_function_tool/workflow.yaml rename to python/samples/03-workflows/declarative/agent_to_function_tool/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py b/python/samples/03-workflows/declarative/invoke_function_tool/main.py similarity index 97% rename from python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py rename to python/samples/03-workflows/declarative/invoke_function_tool/main.py index a6eb61a0eb..a6b251ca53 100644 --- a/python/samples/getting_started/workflows/declarative/invoke_function_tool/main.py +++ b/python/samples/03-workflows/declarative/invoke_function_tool/main.py @@ -10,7 +10,7 @@ 5. Capture function output in workflow variables Run with: - python -m samples.getting_started.workflows.declarative.invoke_function_tool.main + python -m samples.03-workflows.declarative.invoke_function_tool.main """ import asyncio diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml b/python/samples/03-workflows/declarative/invoke_function_tool/workflow.yaml similarity index 98% rename from python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml rename to python/samples/03-workflows/declarative/invoke_function_tool/workflow.yaml index bb76d2518a..3bf3ad28c2 100644 --- a/python/samples/getting_started/workflows/declarative/invoke_function_tool/workflow.yaml +++ b/python/samples/03-workflows/declarative/invoke_function_tool/workflow.yaml @@ -26,6 +26,7 @@ actions: output: messages: Local.weatherToolCallItems result: Local.weatherInfo + autoSend: true # Format a human-readable message using another function - kind: InvokeFunctionTool diff --git a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py b/python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py deleted file mode 100644 index e38408de2e..0000000000 --- a/python/samples/getting_started/workflows/declarative/agent_to_function_tool/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Agent to Function Tool workflow sample.""" diff --git a/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py b/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py deleted file mode 100644 index 62a7caaae8..0000000000 --- a/python/samples/getting_started/workflows/declarative/invoke_function_tool/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Invoke Function Tool sample workflow. - -This sample demonstrates using the InvokeFunctionTool action to invoke -registered Python functions from declarative workflows. -""" From 92bfc73c5b852b9f90eb2919746516cbb67b1640 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 25 Feb 2026 07:43:43 +0900 Subject: [PATCH 6/6] pin azure-ai-projects to 2.0.0b3 due to breaking changes --- .../packages/core/agent_framework/_tools.py | 16 +- python/packages/core/pyproject.toml | 3 +- python/uv.lock | 179 ++++++++---------- 3 files changed, 90 insertions(+), 108 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index df0608e614..91dac40fc7 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -2164,15 +2164,11 @@ async def _get_response() -> ChatResponse: # Error threshold reached: force a final non-tool turn so # function_call_output items are submitted before exit. mutable_options["tool_choice"] = "none" - elif ( - max_function_calls is not None - and total_function_calls >= max_function_calls - ): + elif max_function_calls is not None and total_function_calls >= max_function_calls: # Best-effort limit: checked after each batch of parallel calls completes, # so the current batch always runs to completion even if it overshoots. logger.info( - "Maximum function calls reached (%d/%d). " - "Stopping further function calls for this request.", + "Maximum function calls reached (%d/%d). Stopping further function calls for this request.", total_function_calls, max_function_calls, ) @@ -2302,15 +2298,11 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: mutable_options["tool_choice"] = "none" elif result["action"] != "continue": return - elif ( - max_function_calls is not None - and total_function_calls >= max_function_calls - ): + elif max_function_calls is not None and total_function_calls >= max_function_calls: # Best-effort limit: checked after each batch of parallel calls completes, # so the current batch always runs to completion even if it overshoots. logger.info( - "Maximum function calls reached (%d/%d). " - "Stopping further function calls for this request.", + "Maximum function calls reached (%d/%d). Stopping further function calls for this request.", total_function_calls, max_function_calls, ) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 9b0c9e60a8..09ccda3c18 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -34,7 +34,8 @@ dependencies = [ # connectors and functions "openai>=1.99.0", "azure-identity>=1,<2", - "azure-ai-projects >= 2.0.0b3", + # Pinned to 2.0.0b3 - breaking changes in 2.0.0b4, unpin once upgrades complete + "azure-ai-projects == 2.0.0b3", "mcp[ws]>=1.24.0,<2", "packaging>=24.1", ] diff --git a/python/uv.lock b/python/uv.lock index 4231b3ed67..f5c2efd880 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -383,7 +383,7 @@ requires-dist = [ { name = "agent-framework-orchestrations", marker = "extra == 'all'", editable = "packages/orchestrations" }, { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, - { name = "azure-ai-projects", specifier = ">=2.0.0b3" }, + { name = "azure-ai-projects", specifier = "==2.0.0b3" }, { name = "azure-identity", specifier = ">=1,<2" }, { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, { name = "openai", specifier = ">=1.99.0" }, @@ -1330,19 +1330,19 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.40" +version = "0.1.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/e3/fc014cc5fe1cc6ed1dfcf5940e11808f69d8fcb1d40b5b4e93fb5d469854/claude_agent_sdk-0.1.40.tar.gz", hash = "sha256:8e0db071853c4a45326e89594d6238f7b3cbbf77d9cd7d8da5b851135d8d4e71", size = 62436, upload-time = "2026-02-24T01:59:58.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/f9/4dfd1cfaa7271956bb86e73259c3a87fc032a03c28333db20aefde8706ab/claude_agent_sdk-0.1.41.tar.gz", hash = "sha256:b2b56875fe9b7b389406b53c9020794caf0a29f2b3597b2eca78a61800f9a914", size = 62440, upload-time = "2026-02-24T06:56:09.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/3e/285fc2bbe44fe16ed6391908edd54214f58ba6449ad537eb8f3c46275c5d/claude_agent_sdk-0.1.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6dec9062a2147c724cab99160f2ae131297272d07e93911e6ac41a5e28aea1fe", size = 55607671, upload-time = "2026-02-24T01:59:44.108Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/6d58f6072a30bd94b5bef5013b002c53ceeea1f7edc584ca08e9af4897ae/claude_agent_sdk-0.1.40-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:b2928a40bad21d490ec12981d48cdb4ead62dc02cf8c98f284e823458a7f54d2", size = 70323820, upload-time = "2026-02-24T01:59:47.076Z" }, - { url = "https://files.pythonhosted.org/packages/e3/89/85dc86f841214a549e4e90d9f0faadf9c8dc5cbcbfa0a0d97f8dabeb7cc4/claude_agent_sdk-0.1.40-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:666a0549357ade4ff2fc5feee7fff9b7757f6a58bda6aabddd3158c7d8160182", size = 70924563, upload-time = "2026-02-24T01:59:50.498Z" }, - { url = "https://files.pythonhosted.org/packages/57/33/33c5396f201a176b774fbcd41fa4e451959486a14b2f31d9e6e12ac640d1/claude_agent_sdk-0.1.40-py3-none-win_amd64.whl", hash = "sha256:bd4faf4af50e7b352838bf507b117310998197e4d0aadd24b6e8ba13d0b8ac98", size = 73265179, upload-time = "2026-02-24T01:59:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9b/ebb88bb665c2dfea9c21690f56a4397d647be3ed911de173e2f12c2d69ca/claude_agent_sdk-0.1.41-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4484149ac440393a904306016bf2fffec5d2d5a76e6c559d18f3e25699f5ec87", size = 55601983, upload-time = "2026-02-24T06:55:55.695Z" }, + { url = "https://files.pythonhosted.org/packages/f8/33/e5a85efaa716be0325a581446c3f85805719383fff206ca0da54c0d84783/claude_agent_sdk-0.1.41-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d155772d607f1cffbee6ac6018834aee090e98b2d3a8db52be6302b768e354a7", size = 70326675, upload-time = "2026-02-24T06:55:59.486Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/52e62853898766b83575e06bfaee1e6306b31a07a3171dc1dfec2738ce5c/claude_agent_sdk-0.1.41-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:24a48725cb764443861bf4eb17af1f6a323c72b39fe1a7d703bf29f744c64ad1", size = 70923548, upload-time = "2026-02-24T06:56:02.776Z" }, + { url = "https://files.pythonhosted.org/packages/75/f9/584dd08c0ea9af2c0b9ba406dad819a167b757ef8d23db8135ba4a7b177f/claude_agent_sdk-0.1.41-py3-none-win_amd64.whl", hash = "sha256:bca68993c0d2663f6046eff9ac26a03fbc4fd0eba210ab8a4a76fb00930cb8a1", size = 73259355, upload-time = "2026-02-24T06:56:06.295Z" }, ] [[package]] @@ -1859,7 +1859,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.132.0" +version = "0.133.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1868,9 +1868,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/55/f1b4d4e478a0a1b4b1113d0f610a1b08e539b69900f97fdc97155d62fdee/fastapi-0.132.0.tar.gz", hash = "sha256:ef687847936d8a57ea6ea04cf9a85fe5f2c6ba64e22bfa721467094b69d48d92", size = 372422, upload-time = "2026-02-23T17:56:22.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/04/ab382c7c03dd545f2c964d06e87ad0d5faa944a2434186ad9c285f5d87e0/fastapi-0.133.0.tar.gz", hash = "sha256:b900a2bf5685cdb0647a41d5900bdeafc3a9e8a28ac08c6246b76699e164d60d", size = 373265, upload-time = "2026-02-24T09:53:40.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787, upload-time = "2026-02-24T09:53:41.404Z" }, ] [[package]] @@ -2818,15 +2818,9 @@ wheels = [ [[package]] name = "jsonpath-ng" -version = "1.7.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ply", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/86/08646239a313f895186ff0a4573452038eed8c86f54380b3ebac34d32fb2/jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c", size = 37838, upload-time = "2024-10-11T15:41:42.404Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/5a/73ecb3d82f8615f32ccdadeb9356726d6cae3a4bbc840b437ceb95708063/jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6", size = 30105, upload-time = "2024-11-20T17:58:30.418Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } [[package]] name = "jsonschema" @@ -3071,7 +3065,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.81.14" +version = "1.81.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3087,9 +3081,9 @@ dependencies = [ { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/ab/4fe5517ac55f72ca90119cd0d894a7b4e394ae76e1ccdeb775bd50154b0d/litellm-1.81.14.tar.gz", hash = "sha256:445efb92ae359e8f40ee984753c5ae752535eb18a2aeef00d3089922de5676b7", size = 16541822, upload-time = "2026-02-22T00:33:35.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/0c/62a0fdc5adae6d205338f9239175aa6a93818e58b75cf000a9c7214a3d9f/litellm-1.81.15.tar.gz", hash = "sha256:a8a6277a53280762051c5818ebc76dd5f036368b9426c6f21795ae7f1ac6ebdc", size = 16597039, upload-time = "2026-02-24T06:52:50.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/b3/e8fe151c1b81666575552835a3a79127c5aa6bd460fcecc51e032d2f4019/litellm-1.81.14-py3-none-any.whl", hash = "sha256:6394e61bbdef7121e5e3800349f6b01e9369e7cf611e034f1832750c481abfed", size = 14603260, upload-time = "2026-02-22T00:33:32.464Z" }, + { url = "https://files.pythonhosted.org/packages/78/fd/da11826dda0d332e360b9ead6c0c992d612ecb85b00df494823843cfcda3/litellm-1.81.15-py3-none-any.whl", hash = "sha256:2fa253658702509ce09fe0e172e5a47baaadf697fb0f784c7fd4ff665ae76ae1", size = 14682123, upload-time = "2026-02-24T06:52:48.084Z" }, ] [package.optional-dependencies] @@ -3132,11 +3126,11 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.46" +version = "0.4.47" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/91/8f18ed72b1ad137b40b469820fe69740937de71907b779eb6749357f0a2a/litellm_proxy_extras-0.4.46.tar.gz", hash = "sha256:8bc8636432779c2f3b14f97551e6768b5e069cc3a77a8eecc62a732ae3e3b2d0", size = 26996, upload-time = "2026-02-21T20:03:26.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e6/f4b7929116d7765e2983223a76f77ba7d597fcb4d7ba7a2cd5632037f7dd/litellm_proxy_extras-0.4.47.tar.gz", hash = "sha256:42d88929f9eaf0b827046d3712095354db843c1716ccabb2a40c806ea5f809b9", size = 28010, upload-time = "2026-02-24T03:31:11.446Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/49/07f64e8ced7a89b248d8cddd00f4697030515b327f6096d9daec52e36853/litellm_proxy_extras-0.4.46-py3-none-any.whl", hash = "sha256:dc828c9c00fb53e8e712025ef76f795d261b6f99f6a07a6816fc8761bd76f5de", size = 61749, upload-time = "2026-02-21T20:03:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6d/f147ff929f2079fdda10ff461816679c4cc18d49495cce3c0ef658696a91/litellm_proxy_extras-0.4.47-py3-none-any.whl", hash = "sha256:2e900ae3edfbc20d27556f092d914974d37bac213efe88b8fd5287f77b2b7ca7", size = 63670, upload-time = "2026-02-24T03:31:13.047Z" }, ] [[package]] @@ -3383,31 +3377,31 @@ wheels = [ [[package]] name = "microsoft-agents-activity" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/e9/7f8086719f28815baca72b2f2600ce1e62b7cd53826bb406c52f28e81998/microsoft_agents_activity-0.7.0.tar.gz", hash = "sha256:77eeb6ffa9ee9e6237e1dbf5e962ea641ff60f20b0966e68e903ffbc10ebd41d", size = 60673, upload-time = "2026-01-21T18:05:24.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/8a/3dbdf47f3ddabf646987ddf6f5260e77865c6812177b8759f1c7fc395ac8/microsoft_agents_activity-0.8.0.tar.gz", hash = "sha256:f9e7d92db119cf93dd0642a5e698732c40a450c064306ad076b0d83d95eae114", size = 61226, upload-time = "2026-02-24T18:28:49.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/f3/64dc3bf13e46c6a09cc1983f66da2e42bf726586fe0f77f915977a6be7d8/microsoft_agents_activity-0.7.0-py3-none-any.whl", hash = "sha256:8d30a25dfd0f491b834be52b4a21ff90ab3b9360ec7e50770c050f1d4a39e5ce", size = 132592, upload-time = "2026-01-21T18:05:33.533Z" }, + { url = "https://files.pythonhosted.org/packages/f8/10/18b87c552112917496256d4e9e50a49bd712015d285f01a3c6e18cdfdd74/microsoft_agents_activity-0.8.0-py3-none-any.whl", hash = "sha256:16f0e7fd5ba8f64c43ceac514b7b22734e97b4478b7e97963232ca893cfe336d", size = 132917, upload-time = "2026-02-24T18:28:59.002Z" }, ] [[package]] name = "microsoft-agents-copilotstudio-client" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/6e/6f3c6c2df7e6bc13b44eaca70696d29a76d18b884f125fc892ac2fb689b2/microsoft_agents_copilotstudio_client-0.7.0.tar.gz", hash = "sha256:2e6d7b8d2fccf313f6dffd3df17a21137730151c0557ad1ec08c6fb631a30d5f", size = 12636, upload-time = "2026-01-21T18:05:26.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/5d/a8567b03ff7d29d575aa8c4ebfb53d3f6ee8765cedd8550fae68e9df917d/microsoft_agents_copilotstudio_client-0.8.0.tar.gz", hash = "sha256:7416b2e7906977bd55b66f0b23853fb0c55d4a367cc8bf30cc8aba63d0949514", size = 27196, upload-time = "2026-02-24T18:28:52.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/6d/023ea0254ccb3b97ee36540df2d87aa718912f70d6b6c529569fae675ca3/microsoft_agents_copilotstudio_client-0.7.0-py3-none-any.whl", hash = "sha256:a69947c49e782b552c5ede877277e73a86280aa2335a291f08fe3622ebfdabe9", size = 13425, upload-time = "2026-01-21T18:05:35.729Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6b/999ab044edfe924f0330bd2ce200f3fa9c2a84550212587781c68d617679/microsoft_agents_copilotstudio_client-0.8.0-py3-none-any.whl", hash = "sha256:d00936e2a0b48482380d81695f00af86d71c82c0b464947cc723834b63c91553", size = 23715, upload-time = "2026-02-24T18:29:01.3Z" }, ] [[package]] name = "microsoft-agents-hosting-core" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3416,9 +3410,9 @@ dependencies = [ { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/da/26d461cb222ab41f38a3c72ca43900a65b8e8b6b71d6d1207fad1edc3e7b/microsoft_agents_hosting_core-0.7.0.tar.gz", hash = "sha256:31448279c47e39d63edc347c1d3b4de8043aa1b4c51a1f01d40d7d451221b202", size = 90446, upload-time = "2026-01-21T18:05:29.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/8a/5ab47498bbc74989c30dbfbcb7862211117bdbeba4e3d844bb281c0e05bf/microsoft_agents_hosting_core-0.8.0.tar.gz", hash = "sha256:d3b34803f73d7f677b797733dfe5c561af876e8721c426d6379a762fe6e86fa4", size = 94079, upload-time = "2026-02-24T18:28:54.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e4/8d9e2e3f3a3106d0c80141631385206a6946f0b414cf863db851b98533e7/microsoft_agents_hosting_core-0.7.0-py3-none-any.whl", hash = "sha256:d03549fff01f38c1a96da4f79375c33378205ee9b5c6e01b87ba576f59b7887f", size = 133749, upload-time = "2026-01-21T18:05:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ff/a1497b3ea63ab0658518fc18532179e5696c5d8d7b28683ec82c34323e54/microsoft_agents_hosting_core-0.8.0-py3-none-any.whl", hash = "sha256:603f53f14bebc7888b5664718bbd24038dafffdd282c81d0e635fca7acfc6aef", size = 139555, upload-time = "2026-02-24T18:29:03.479Z" }, ] [[package]] @@ -3478,16 +3472,16 @@ wheels = [ [[package]] name = "msal" -version = "1.34.0" +version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057, upload-time = "2026-02-24T10:58:28.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082, upload-time = "2026-02-24T10:58:27.219Z" }, ] [[package]] @@ -3897,7 +3891,7 @@ wheels = [ [[package]] name = "openai" -version = "2.23.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3909,9 +3903,9 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/4b/dc1d84b8237205ebe48a1b1c9c3a8e1ab9fd08b30811b6d787196df58fd6/openai-2.23.0.tar.gz", hash = "sha256:7d24cc8087d5e8eed58e98aaa823391d39d12f9a9a2755770f67c7bb2004d94c", size = 657323, upload-time = "2026-02-24T03:20:20.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/5f/bcdf0fb510c24f021e485f920677da363cd59d6e0310171bf2cad6e052b5/openai-2.23.0-py3-none-any.whl", hash = "sha256:1041d40bebf845053fda1946104f8bf9c3e2df957a41c3878c55c72c352630e9", size = 1118971, upload-time = "2026-02-24T03:20:18.708Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -4497,15 +4491,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "ply" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, -] - [[package]] name = "poethepoet" version = "0.42.0" @@ -6163,58 +6148,62 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.46" +version = "2.0.47" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'WIN32' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and sys_platform == 'darwin') or (platform_machine == 'win32' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'WIN32' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and sys_platform == 'linux') or (platform_machine == 'win32' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'WIN32' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and sys_platform == 'win32') or (platform_machine == 'win32' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/26/66ba59328dc25e523bfcb0f8db48bdebe2035e0159d600e1f01c0fc93967/sqlalchemy-2.0.46-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:895296687ad06dc9b11a024cf68e8d9d3943aa0b4964278d2553b86f1b267735", size = 2155051, upload-time = "2026-01-21T18:27:28.965Z" }, - { url = "https://files.pythonhosted.org/packages/21/cd/9336732941df972fbbfa394db9caa8bb0cf9fe03656ec728d12e9cbd6edc/sqlalchemy-2.0.46-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab65cb2885a9f80f979b85aa4e9c9165a31381ca322cbde7c638fe6eefd1ec39", size = 3234666, upload-time = "2026-01-21T18:32:28.72Z" }, - { url = "https://files.pythonhosted.org/packages/38/62/865ae8b739930ec433cd4123760bee7f8dafdc10abefd725a025604fb0de/sqlalchemy-2.0.46-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52fe29b3817bd191cc20bad564237c808967972c97fa683c04b28ec8979ae36f", size = 3232917, upload-time = "2026-01-21T18:44:54.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/38/805904b911857f2b5e00fdea44e9570df62110f834378706939825579296/sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:09168817d6c19954d3b7655da6ba87fcb3a62bb575fb396a81a8b6a9fadfe8b5", size = 3185790, upload-time = "2026-01-21T18:32:30.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/4f/3260bb53aabd2d274856337456ea52f6a7eccf6cce208e558f870cec766b/sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be6c0466b4c25b44c5d82b0426b5501de3c424d7a3220e86cd32f319ba56798e", size = 3207206, upload-time = "2026-01-21T18:44:55.93Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b3/67c432d7f9d88bb1a61909b67e29f6354d59186c168fb5d381cf438d3b73/sqlalchemy-2.0.46-cp310-cp310-win32.whl", hash = "sha256:1bc3f601f0a818d27bfe139f6766487d9c88502062a2cd3a7ee6c342e81d5047", size = 2115296, upload-time = "2026-01-21T18:33:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/4a/8c/25fb284f570f9d48e6c240f0269a50cec9cf009a7e08be4c0aaaf0654972/sqlalchemy-2.0.46-cp310-cp310-win_amd64.whl", hash = "sha256:e0c05aff5c6b1bb5fb46a87e0f9d2f733f83ef6cbbbcd5c642b6c01678268061", size = 2138540, upload-time = "2026-01-21T18:33:14.22Z" }, - { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" }, - { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" }, - { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" }, - { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, - { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, - { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, - { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, - { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, - { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, - { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, - { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, - { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, - { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, - { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, - { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/75/17db77c57129c223c7d98518ad1e1faa24ee350c22a44b55390d8463c28c/sqlalchemy-2.0.47-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33a917ede39406ddb93c3e642b5bc480be7c5fd0f3d0d6ae1036d466fb963f1a", size = 2157331, upload-time = "2026-02-24T16:43:52.693Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d6/3658f7e5c376de774c009f2bb9c0ddf88a35b89c5bfb15ee7174a17b1a5f/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:561d027c829b01e040bdade6b6f5b429249d056ef95d7bdcb9211539ecc82803", size = 3236939, upload-time = "2026-02-24T17:28:57.419Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/f4b94f85d1c26cb9ee0e57449754de816c326f9586b9a8c5247eb49146de/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa5072a37e68c565363c009b7afa5b199b488c87940ec02719860093a08f34ca", size = 3235190, upload-time = "2026-02-24T17:27:07.884Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/36714f1de01e135a2bf142b662e416e5338ab63c47878e31051338c66e2d/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e7ed17dd4312a298b6024bfd1baf51654bc49e3f03c798005babf0c7922d6a7", size = 3188064, upload-time = "2026-02-24T17:28:58.908Z" }, + { url = "https://files.pythonhosted.org/packages/ab/94/fcd978e7625cd1c97d9f1d7363e18e37d24314e572acd7c091e3a4210106/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6992e353fcb0593eb42d95ad84b3e58fe40b5e37fd332b9ccba28f4b2f36d1fc", size = 3209480, upload-time = "2026-02-24T17:27:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/23/29/c633202b9900ab65f0162f59df737b57f30010f44d892b186810c9ed58b7/sqlalchemy-2.0.47-cp310-cp310-win32.whl", hash = "sha256:05a6d58ed99ebd01303c92d29a0c9cbf70f637b3ddd155f5172c5a7239940998", size = 2117652, upload-time = "2026-02-24T17:14:34.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/54acf13913932b8508058d47a169e6fcde9adaa4cbfa16cbf30da1f6a482/sqlalchemy-2.0.47-cp310-cp310-win_amd64.whl", hash = "sha256:4a7aa4a584cc97e268c11e700dea0b763874eaebb435e75e7d0ffee5d90f5030", size = 2140883, upload-time = "2026-02-24T17:14:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/886338d3e8ab5ddcfe84d54302c749b1793e16c4bba63d7004e3f7baa8ec/sqlalchemy-2.0.47-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a1dbf0913879c443617d6b64403cf2801c941651db8c60e96d204ed9388d6b0", size = 2157124, upload-time = "2026-02-24T16:43:54.706Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bb/a897f6a66c9986aa9f27f5cf8550637d8a5ea368fd7fb42f6dac3105b4dc/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:775effbb97ea3b00c4dd3aeaf3ba8acba6e3e2b4b41d17d67a27e696843dbc95", size = 3313513, upload-time = "2026-02-24T17:29:00.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/fb/69bfae022b681507565ab0d34f0c80aa1e9f954a5a7cbfb0ed054966ac8d/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56cc834a3ffac34270cc2a41875e0f40e97aa651f4f3ca1cfbbf421c044cb62b", size = 3313014, upload-time = "2026-02-24T17:27:11.679Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/0eba329f7c182d53205a228c4fd24651b95489b431ea2bd830887b4c13c4/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49b5e0c7244262f39e767c018e4fdb5e5dbc23cd54c5ddac8eea8f0ba32ef890", size = 3265389, upload-time = "2026-02-24T17:29:02.497Z" }, + { url = "https://files.pythonhosted.org/packages/5c/06/654edc084b3b46ac79e04200d7c46467ae80c759c4ee41c897f9272b036f/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cd822a3f1f6f77b5b841a30c1a07a07f7dee3385f17e638e1722de9ab683be", size = 3287604, upload-time = "2026-02-24T17:27:13.295Z" }, + { url = "https://files.pythonhosted.org/packages/78/33/c18c8f63b61981219d3aa12321bb7ccee605034d195e868ed94f9727b27c/sqlalchemy-2.0.47-cp311-cp311-win32.whl", hash = "sha256:9847a19548cd283a65e1ce0afd54016598d55ff72682d6fd3e493af6fc044064", size = 2116916, upload-time = "2026-02-24T17:14:37.392Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a59e3f9796fff844e16afbd821db9abfd6e12698db9441a231a96193a100/sqlalchemy-2.0.47-cp311-cp311-win_amd64.whl", hash = "sha256:722abf1c82aeca46a1a0803711244a48a298279eeaec9e02f7bfee9e064182e5", size = 2141587, upload-time = "2026-02-24T17:14:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/80/88/74eb470223ff88ea6572a132c0b8de8c1d8ed7b843d3b44a8a3c77f31d39/sqlalchemy-2.0.47-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fa91b19d6b9821c04cc8f7aa2476429cc8887b9687c762815aa629f5c0edec1", size = 2155687, upload-time = "2026-02-24T17:05:46.451Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/1447d3d558971b036cb93b557595cb5dcdfe728f1c7ac4dec16505ef5756/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c5bbbd14eff577c8c79cbfe39a0771eecd20f430f3678533476f0087138f356", size = 3336978, upload-time = "2026-02-24T17:18:04.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/07/b47472d2ffd0776826f17ccf0b4d01b224c99fbd1904aeb103dffbb4b1cc/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a6c555da8d4280a3c4c78c5b7a3f990cee2b2884e5f934f87a226191682ff7", size = 3349939, upload-time = "2026-02-24T17:27:18.937Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c6/95fa32b79b57769da3e16f054cf658d90940317b5ca0ec20eac84aa19c4f/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ed48a1701d24dff3bb49a5bce94d6bc84cbe33d98af2aa2d3cdcce3dea1709ec", size = 3279648, upload-time = "2026-02-24T17:18:07.038Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c8/3d07e7c73928dc59a0bed40961ca4e313e797bce650b088e8d5fdd3ad939/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f3178c920ad98158f0b6309382194df04b14808fa6052ae07099fdde29d5602", size = 3314695, upload-time = "2026-02-24T17:27:20.93Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed32b1611c1e19fdb028eee1adc5a9aa138c2952d09ae11f1670170f80ae/sqlalchemy-2.0.47-cp312-cp312-win32.whl", hash = "sha256:b9c11ac9934dd59ece9619fe42780a08abe2faab7b0543bb00d5eabea4f421b9", size = 2115502, upload-time = "2026-02-24T17:22:52.546Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/9de590356a4dd8e9ef5a881dbba64b2bbc4cbc71bf02bc68e775fb9b1899/sqlalchemy-2.0.47-cp312-cp312-win_amd64.whl", hash = "sha256:db43b72cf8274a99e089755c9c1e0b947159b71adbc2c83c3de2e38d5d607acb", size = 2142435, upload-time = "2026-02-24T17:22:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" }, + { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" }, + { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" }, + { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" }, + { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, + { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, + { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, ] [[package]]