[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:
Evan Mattson
2026-02-25 07:54:35 +09:00
committed by GitHub
Unverified
parent f77f40b987
commit 40d2fac29c
23 changed files with 5089 additions and 682 deletions
@@ -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
@@ -0,0 +1,261 @@
# 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.03-workflows.declarative.agent_to_function_tool.main
"""
import asyncio
from pathlib import Path
from typing import Any
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(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}")
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -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
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
"""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 InvokeFunctionTool 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.03-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())
@@ -0,0 +1,51 @@
# 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
autoSend: true
# 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