mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Add InvokeFunctionTool action for declarative workflows (#3716)
* add(declarative): Declarative workflow InvokeFunctionTool feature * Cleanup * Address PR feedback * Remove InvokeTool kind, consolidate to InvokeFunctionTool * Fix sample locations * pin azure-ai-projects to 2.0.0b3 due to breaking changes
This commit is contained in:
committed by
GitHub
Unverified
parent
f77f40b987
commit
40d2fac29c
@@ -34,7 +34,6 @@ from ._executors_agents import (
|
||||
AgentResult,
|
||||
ExternalLoopState,
|
||||
InvokeAzureAgentExecutor,
|
||||
InvokeToolExecutor,
|
||||
)
|
||||
from ._executors_basic import (
|
||||
BASIC_ACTION_EXECUTORS,
|
||||
@@ -68,6 +67,17 @@ from ._executors_external_input import (
|
||||
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 ._state import WorkflowState
|
||||
|
||||
@@ -79,6 +89,9 @@ __all__ = [
|
||||
"CONTROL_FLOW_EXECUTORS",
|
||||
"DECLARATIVE_STATE_KEY",
|
||||
"EXTERNAL_INPUT_EXECUTORS",
|
||||
"FUNCTION_TOOL_REGISTRY_KEY",
|
||||
"TOOL_ACTION_EXECUTORS",
|
||||
"TOOL_APPROVAL_STATE_KEY",
|
||||
"TOOL_REGISTRY_KEY",
|
||||
"ActionComplete",
|
||||
"ActionTrigger",
|
||||
@@ -86,6 +99,7 @@ __all__ = [
|
||||
"AgentExternalInputResponse",
|
||||
"AgentResult",
|
||||
"AppendValueExecutor",
|
||||
"BaseToolExecutor",
|
||||
"BreakLoopExecutor",
|
||||
"ClearAllVariablesExecutor",
|
||||
"ConfirmationExecutor",
|
||||
@@ -107,7 +121,7 @@ __all__ = [
|
||||
"ForeachInitExecutor",
|
||||
"ForeachNextExecutor",
|
||||
"InvokeAzureAgentExecutor",
|
||||
"InvokeToolExecutor",
|
||||
"InvokeFunctionToolExecutor",
|
||||
"JoinExecutor",
|
||||
"LoopControl",
|
||||
"LoopIterationResult",
|
||||
@@ -119,6 +133,10 @@ __all__ = [
|
||||
"SetTextVariableExecutor",
|
||||
"SetValueExecutor",
|
||||
"SetVariableExecutor",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"ToolApprovalState",
|
||||
"ToolInvocationResult",
|
||||
"WaitForInputExecutor",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
|
||||
+20
-3
@@ -13,6 +13,7 @@ action definitions and creates a proper workflow graph with:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
@@ -38,6 +39,10 @@ from ._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
|
||||
from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Combined mapping of all action kinds to executor classes
|
||||
ALL_ACTION_EXECUTORS = {
|
||||
@@ -45,6 +50,7 @@ ALL_ACTION_EXECUTORS = {
|
||||
**CONTROL_FLOW_EXECUTORS,
|
||||
**AGENT_ACTION_EXECUTORS,
|
||||
**EXTERNAL_INPUT_EXECUTORS,
|
||||
**TOOL_ACTION_EXECUTORS,
|
||||
}
|
||||
|
||||
# Action kinds that terminate control flow (no fall-through to successor)
|
||||
@@ -78,6 +84,7 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"RequestHumanInput": ["variable"],
|
||||
"WaitForHumanInput": ["variable"],
|
||||
"EmitEvent": ["event"],
|
||||
"InvokeFunctionTool": ["functionName"],
|
||||
}
|
||||
|
||||
# Alternate field names that satisfy required field requirements
|
||||
@@ -118,6 +125,7 @@ class DeclarativeWorkflowBuilder:
|
||||
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,
|
||||
max_iterations: int | None = None,
|
||||
@@ -128,6 +136,7 @@ class DeclarativeWorkflowBuilder:
|
||||
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 InvokeFunctionTool actions)
|
||||
checkpoint_storage: Optional checkpoint storage for pause/resume support
|
||||
validate: Whether to validate the workflow definition before building (default: True)
|
||||
max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
|
||||
@@ -138,6 +147,7 @@ class DeclarativeWorkflowBuilder:
|
||||
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
|
||||
@@ -423,8 +433,13 @@ class DeclarativeWorkflowBuilder:
|
||||
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
|
||||
@@ -437,10 +452,12 @@ class DeclarativeWorkflowBuilder:
|
||||
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
|
||||
|
||||
-68
@@ -1019,75 +1019,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
||||
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,
|
||||
}
|
||||
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
# 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 (
|
||||
Content,
|
||||
Message,
|
||||
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 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 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"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
function_name: str
|
||||
arguments: dict[str, Any]
|
||||
|
||||
|
||||
@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 State under a per-executor 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
|
||||
auto_send: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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: Message 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[Message] = 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 (State + WorkflowFactory registration)
|
||||
- Approval flow (request_info pattern with yield/resume)
|
||||
- Output formatting (messages as Message 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: <ToolKind>
|
||||
id: unique_id
|
||||
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 # Message list
|
||||
result: Local.toolResult
|
||||
autoSend: true # optional, default=true
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
def _get_tool(
|
||||
self,
|
||||
function_name: str,
|
||||
ctx: WorkflowContext[Any, Any],
|
||||
) -> Any | None:
|
||||
"""Get tool from registry.
|
||||
|
||||
Checks both WorkflowFactory registry (self._tools) and State 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 State registry (for runtime registration)
|
||||
try:
|
||||
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 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, bool]:
|
||||
"""Parse output configuration from action definition.
|
||||
|
||||
Returns:
|
||||
Tuple of (messages_var, result_var, auto_send)
|
||||
"""
|
||||
output_config = self._action_def.get("output", {})
|
||||
|
||||
if not isinstance(output_config, dict):
|
||||
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(
|
||||
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)
|
||||
state.set(path, result.messages)
|
||||
|
||||
# Store result if variable specified
|
||||
if result_var:
|
||||
path = _normalize_variable_path(result_var)
|
||||
if result.rejected:
|
||||
state.set(
|
||||
path,
|
||||
{
|
||||
"approved": False,
|
||||
"rejected": True,
|
||||
"reason": result.rejection_reason,
|
||||
},
|
||||
)
|
||||
elif result.success:
|
||||
state.set(path, result.result)
|
||||
else:
|
||||
state.set(
|
||||
path,
|
||||
{
|
||||
"error": result.error,
|
||||
},
|
||||
)
|
||||
|
||||
async def _format_messages(
|
||||
self,
|
||||
function_name: str,
|
||||
arguments: dict[str, Any],
|
||||
result: Any,
|
||||
) -> list[Message]:
|
||||
"""Format tool invocation as Message 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 Message 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 = Message(
|
||||
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 = Message(
|
||||
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 = 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 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
|
||||
"""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Parse output configuration early so we can store errors
|
||||
messages_var, result_var, auto_send = 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:
|
||||
state.set(_normalize_variable_path(result_var), {"error": error_msg})
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
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:
|
||||
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] = state.eval_if_expression(value)
|
||||
|
||||
# Check if approval is required
|
||||
require_approval = self._action_def.get("requireApproval", False)
|
||||
|
||||
if require_approval:
|
||||
# Save state for resumption (keyed by executor ID to avoid collisions)
|
||||
approval_state = ToolApprovalState(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
output_messages_var=messages_var,
|
||||
output_result_var=result_var,
|
||||
auto_send=auto_send,
|
||||
)
|
||||
approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}"
|
||||
ctx.state.set(approval_key, approval_state)
|
||||
|
||||
# Emit approval request - workflow yields here
|
||||
request = ToolApprovalRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
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.state)
|
||||
approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}"
|
||||
|
||||
# Retrieve saved invocation state
|
||||
try:
|
||||
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:
|
||||
state.set(_normalize_variable_path(result_var), {"error": error_msg})
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
# Clean up approval state
|
||||
try:
|
||||
ctx.state.delete(approval_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
|
||||
auto_send = approval_state.auto_send
|
||||
|
||||
# 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=[
|
||||
Message(
|
||||
role="assistant",
|
||||
text=f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}",
|
||||
)
|
||||
],
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
- Message list output for conversation history
|
||||
|
||||
YAML Schema:
|
||||
kind: InvokeFunctionTool
|
||||
id: invoke_function_example
|
||||
functionName: get_weather # required, supports =expression syntax
|
||||
requireApproval: true # optional, default=false
|
||||
arguments: # optional dictionary
|
||||
location: =Local.location
|
||||
unit: F
|
||||
output:
|
||||
messages: Local.weatherToolCallItems # Message list
|
||||
result: Local.WeatherInfo
|
||||
autoSend: true # optional, default=true
|
||||
|
||||
Tool Registration:
|
||||
Tools can be registered via:
|
||||
1. WorkflowFactory.register_tool("name", func) - preferred
|
||||
2. Setting FUNCTION_TOOL_REGISTRY_KEY in State 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,
|
||||
}
|
||||
@@ -141,6 +141,7 @@ class WorkflowFactory:
|
||||
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
|
||||
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
|
||||
self._bindings: dict[str, Any] = dict(bindings) if bindings else {}
|
||||
self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
self._max_iterations = max_iterations
|
||||
|
||||
@@ -377,12 +378,13 @@ class WorkflowFactory:
|
||||
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,
|
||||
max_iterations=self._max_iterations,
|
||||
)
|
||||
@@ -390,9 +392,10 @@ class WorkflowFactory:
|
||||
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
|
||||
@@ -598,9 +601,66 @@ class WorkflowFactory:
|
||||
|
||||
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
|
||||
|
||||
def register_tool(self, name: str, func: Any) -> WorkflowFactory:
|
||||
"""Register a function with the factory for use in InvokeFunctionTool actions.
|
||||
|
||||
Registered functions 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 InvokeFunctionTool 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 InvokeFunctionTool 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: InvokeFunctionTool
|
||||
id: call_weather
|
||||
functionName: get_weather
|
||||
arguments:
|
||||
location: =Local.city
|
||||
unit: F
|
||||
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
|
||||
|
||||
def _convert_inputs_to_json_schema(self, inputs_def: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert a declarative inputs definition to JSON Schema.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user