Skip to content

Commit 40d2fac

Browse files
authored
[BREAKING] Python: Add InvokeFunctionTool action for declarative workflows (microsoft#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
1 parent f77f40b commit 40d2fac

23 files changed

Lines changed: 5098 additions & 691 deletions

python/packages/core/agent_framework/_tools.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,15 +2164,11 @@ async def _get_response() -> ChatResponse:
21642164
# Error threshold reached: force a final non-tool turn so
21652165
# function_call_output items are submitted before exit.
21662166
mutable_options["tool_choice"] = "none"
2167-
elif (
2168-
max_function_calls is not None
2169-
and total_function_calls >= max_function_calls
2170-
):
2167+
elif max_function_calls is not None and total_function_calls >= max_function_calls:
21712168
# Best-effort limit: checked after each batch of parallel calls completes,
21722169
# so the current batch always runs to completion even if it overshoots.
21732170
logger.info(
2174-
"Maximum function calls reached (%d/%d). "
2175-
"Stopping further function calls for this request.",
2171+
"Maximum function calls reached (%d/%d). Stopping further function calls for this request.",
21762172
total_function_calls,
21772173
max_function_calls,
21782174
)
@@ -2302,15 +2298,11 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
23022298
mutable_options["tool_choice"] = "none"
23032299
elif result["action"] != "continue":
23042300
return
2305-
elif (
2306-
max_function_calls is not None
2307-
and total_function_calls >= max_function_calls
2308-
):
2301+
elif max_function_calls is not None and total_function_calls >= max_function_calls:
23092302
# Best-effort limit: checked after each batch of parallel calls completes,
23102303
# so the current batch always runs to completion even if it overshoots.
23112304
logger.info(
2312-
"Maximum function calls reached (%d/%d). "
2313-
"Stopping further function calls for this request.",
2305+
"Maximum function calls reached (%d/%d). Stopping further function calls for this request.",
23142306
total_function_calls,
23152307
max_function_calls,
23162308
)

python/packages/core/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ dependencies = [
3434
# connectors and functions
3535
"openai>=1.99.0",
3636
"azure-identity>=1,<2",
37-
"azure-ai-projects >= 2.0.0b3",
37+
# Pinned to 2.0.0b3 - breaking changes in 2.0.0b4, unpin once upgrades complete
38+
"azure-ai-projects == 2.0.0b3",
3839
"mcp[ws]>=1.24.0,<2",
3940
"packaging>=24.1",
4041
]

python/packages/declarative/agent_framework_declarative/_workflows/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
AgentResult,
3535
ExternalLoopState,
3636
InvokeAzureAgentExecutor,
37-
InvokeToolExecutor,
3837
)
3938
from ._executors_basic import (
4039
BASIC_ACTION_EXECUTORS,
@@ -68,6 +67,17 @@
6867
RequestExternalInputExecutor,
6968
WaitForInputExecutor,
7069
)
70+
from ._executors_tools import (
71+
FUNCTION_TOOL_REGISTRY_KEY,
72+
TOOL_ACTION_EXECUTORS,
73+
TOOL_APPROVAL_STATE_KEY,
74+
BaseToolExecutor,
75+
InvokeFunctionToolExecutor,
76+
ToolApprovalRequest,
77+
ToolApprovalResponse,
78+
ToolApprovalState,
79+
ToolInvocationResult,
80+
)
7181
from ._factory import DeclarativeWorkflowError, WorkflowFactory
7282
from ._state import WorkflowState
7383

@@ -79,13 +89,17 @@
7989
"CONTROL_FLOW_EXECUTORS",
8090
"DECLARATIVE_STATE_KEY",
8191
"EXTERNAL_INPUT_EXECUTORS",
92+
"FUNCTION_TOOL_REGISTRY_KEY",
93+
"TOOL_ACTION_EXECUTORS",
94+
"TOOL_APPROVAL_STATE_KEY",
8295
"TOOL_REGISTRY_KEY",
8396
"ActionComplete",
8497
"ActionTrigger",
8598
"AgentExternalInputRequest",
8699
"AgentExternalInputResponse",
87100
"AgentResult",
88101
"AppendValueExecutor",
102+
"BaseToolExecutor",
89103
"BreakLoopExecutor",
90104
"ClearAllVariablesExecutor",
91105
"ConfirmationExecutor",
@@ -107,7 +121,7 @@
107121
"ForeachInitExecutor",
108122
"ForeachNextExecutor",
109123
"InvokeAzureAgentExecutor",
110-
"InvokeToolExecutor",
124+
"InvokeFunctionToolExecutor",
111125
"JoinExecutor",
112126
"LoopControl",
113127
"LoopIterationResult",
@@ -119,6 +133,10 @@
119133
"SetTextVariableExecutor",
120134
"SetValueExecutor",
121135
"SetVariableExecutor",
136+
"ToolApprovalRequest",
137+
"ToolApprovalResponse",
138+
"ToolApprovalState",
139+
"ToolInvocationResult",
122140
"WaitForInputExecutor",
123141
"WorkflowFactory",
124142
"WorkflowState",

python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from __future__ import annotations
1515

16+
import logging
1617
from typing import Any
1718

1819
from agent_framework import (
@@ -38,13 +39,18 @@
3839
SwitchEvaluatorExecutor,
3940
)
4041
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
42+
from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
43+
44+
logger = logging.getLogger(__name__)
45+
4146

4247
# Combined mapping of all action kinds to executor classes
4348
ALL_ACTION_EXECUTORS = {
4449
**BASIC_ACTION_EXECUTORS,
4550
**CONTROL_FLOW_EXECUTORS,
4651
**AGENT_ACTION_EXECUTORS,
4752
**EXTERNAL_INPUT_EXECUTORS,
53+
**TOOL_ACTION_EXECUTORS,
4854
}
4955

5056
# Action kinds that terminate control flow (no fall-through to successor)
@@ -78,6 +84,7 @@
7884
"RequestHumanInput": ["variable"],
7985
"WaitForHumanInput": ["variable"],
8086
"EmitEvent": ["event"],
87+
"InvokeFunctionTool": ["functionName"],
8188
}
8289

8390
# Alternate field names that satisfy required field requirements
@@ -118,6 +125,7 @@ def __init__(
118125
yaml_definition: dict[str, Any],
119126
workflow_id: str | None = None,
120127
agents: dict[str, Any] | None = None,
128+
tools: dict[str, Any] | None = None,
121129
checkpoint_storage: Any | None = None,
122130
validate: bool = True,
123131
max_iterations: int | None = None,
@@ -128,6 +136,7 @@ def __init__(
128136
yaml_definition: The parsed YAML workflow definition
129137
workflow_id: Optional ID for the workflow (defaults to name from YAML)
130138
agents: Registry of agent instances by name (for InvokeAzureAgent actions)
139+
tools: Registry of tool/function instances by name (for InvokeFunctionTool actions)
131140
checkpoint_storage: Optional checkpoint storage for pause/resume support
132141
validate: Whether to validate the workflow definition before building (default: True)
133142
max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
@@ -138,6 +147,7 @@ def __init__(
138147
self._executors: dict[str, Any] = {} # id -> executor
139148
self._action_index = 0 # Counter for generating unique IDs
140149
self._agents = agents or {} # Agent registry for agent executors
150+
self._tools = tools or {} # Tool registry for tool executors
141151
self._checkpoint_storage = checkpoint_storage
142152
self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
143153
self._validate = validate
@@ -423,8 +433,13 @@ def _create_executor_for_action(
423433
executor_class = ALL_ACTION_EXECUTORS.get(kind)
424434

425435
if executor_class is None:
426-
# Unknown action type - skip with warning
427-
# In production, might want to log this
436+
# Unknown action type - log warning and skip
437+
logger.warning(
438+
"Unknown action kind '%s' encountered at index %d - action will be skipped. Available action kinds: %s",
439+
kind,
440+
self._action_index,
441+
list(ALL_ACTION_EXECUTORS.keys()),
442+
)
428443
return None
429444

430445
# Create the executor with ID
@@ -437,10 +452,12 @@ def _create_executor_for_action(
437452
action_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}"
438453
self._action_index += 1
439454

440-
# Pass agents to agent-related executors
455+
# Pass agents/tools to specialized executors
441456
executor: Any
442457
if kind in ("InvokeAzureAgent",):
443458
executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents)
459+
elif kind == "InvokeFunctionTool":
460+
executor = InvokeFunctionToolExecutor(action_def, id=action_id, tools=self._tools)
444461
else:
445462
executor = executor_class(action_def, id=action_id)
446463
self._executors[action_id] = executor

python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,75 +1019,7 @@ async def handle_external_input_response(
10191019
await ctx.send_message(ActionComplete())
10201020

10211021

1022-
class InvokeToolExecutor(DeclarativeActionExecutor):
1023-
"""Executor that invokes a registered tool/function.
1024-
1025-
Tools are simpler than agents - they take input, perform an action,
1026-
and return a result synchronously (or with a simple async call).
1027-
"""
1028-
1029-
@handler
1030-
async def handle_action(
1031-
self,
1032-
trigger: Any,
1033-
ctx: WorkflowContext[ActionComplete],
1034-
) -> None:
1035-
"""Handle the tool invocation."""
1036-
state = await self._ensure_state_initialized(ctx, trigger)
1037-
1038-
tool_name = self._action_def.get("tool") or self._action_def.get("toolName", "")
1039-
input_expr = self._action_def.get("input")
1040-
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get("resultProperty")
1041-
parameters = self._action_def.get("parameters", {})
1042-
1043-
# Get tools registry
1044-
try:
1045-
tool_registry: dict[str, Any] | None = ctx.state.get(TOOL_REGISTRY_KEY)
1046-
except KeyError:
1047-
tool_registry = {}
1048-
1049-
tool: Any = tool_registry.get(tool_name) if tool_registry else None
1050-
1051-
if tool is None:
1052-
error_msg = f"Tool '{tool_name}' not found in registry"
1053-
if output_property:
1054-
state.set(output_property, {"error": error_msg})
1055-
await ctx.send_message(ActionComplete())
1056-
return
1057-
1058-
# Build parameters
1059-
params: dict[str, Any] = {}
1060-
for param_name, param_expression in parameters.items():
1061-
params[param_name] = state.eval_if_expression(param_expression)
1062-
1063-
# Add main input if specified
1064-
if input_expr:
1065-
params["input"] = state.eval_if_expression(input_expr)
1066-
1067-
try:
1068-
# Invoke the tool
1069-
if callable(tool):
1070-
from inspect import isawaitable
1071-
1072-
result = tool(**params)
1073-
if isawaitable(result):
1074-
result = await result
1075-
1076-
# Store result
1077-
if output_property:
1078-
state.set(output_property, result)
1079-
1080-
except Exception as e:
1081-
if output_property:
1082-
state.set(output_property, {"error": str(e)})
1083-
await ctx.send_message(ActionComplete())
1084-
return
1085-
1086-
await ctx.send_message(ActionComplete())
1087-
1088-
10891022
# Mapping of agent action kinds to executor classes
10901023
AGENT_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
10911024
"InvokeAzureAgent": InvokeAzureAgentExecutor,
1092-
"InvokeTool": InvokeToolExecutor,
10931025
}

0 commit comments

Comments
 (0)