chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Declarative specification support for Microsoft Agent Framework.
|
||||
|
||||
Release stage:
|
||||
|
||||
* The declarative-workflows surface (``WorkflowFactory``, executors, handlers,
|
||||
etc.) is at release-candidate stability.
|
||||
* The declarative-agents surface (``AgentFactory`` and the YAML agent
|
||||
loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``,
|
||||
``ProviderTypeMapping``) is *experimental* and may change or be removed in
|
||||
future versions without notice. Using these symbols emits an
|
||||
``ExperimentalWarning`` on first use.
|
||||
"""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
|
||||
from ._workflows import (
|
||||
AgentExternalInputRequest,
|
||||
AgentExternalInputResponse,
|
||||
DeclarativeActionError,
|
||||
DeclarativeWorkflowError,
|
||||
DefaultHttpRequestHandler,
|
||||
DefaultMCPToolHandler,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
MCPToolApprovalRequest,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
WorkflowState,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = metadata.version(__name__)
|
||||
except metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentFactory",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,868 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import yaml
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
SupportsChatGetResponse,
|
||||
)
|
||||
from agent_framework import (
|
||||
FunctionTool as AFFunctionTool,
|
||||
)
|
||||
from agent_framework._feature_stage import (
|
||||
ExperimentalFeature,
|
||||
experimental,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from ._models import (
|
||||
AnonymousConnection,
|
||||
ApiKeyConnection,
|
||||
CodeInterpreterTool,
|
||||
FileSearchTool,
|
||||
FunctionTool,
|
||||
McpServerToolSpecifyApprovalMode,
|
||||
McpTool,
|
||||
Model,
|
||||
ModelOptions,
|
||||
PromptAgent,
|
||||
ReferenceConnection,
|
||||
RemoteConnection,
|
||||
Tool,
|
||||
WebSearchTool,
|
||||
_safe_mode_context, # type: ignore[reportPrivateUsage]
|
||||
agent_schema_dispatch,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypedDict # pragma: no cover
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class ProviderTypeMapping(TypedDict, total=True):
|
||||
package: str
|
||||
name: str
|
||||
model_field: str
|
||||
endpoint_field: str | None
|
||||
api_key_field: str | None
|
||||
|
||||
|
||||
PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
|
||||
"AzureOpenAI": {
|
||||
"package": "agent_framework.openai",
|
||||
"name": "OpenAIChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "azure_endpoint",
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
"AzureOpenAI.Chat": {
|
||||
"package": "agent_framework.openai",
|
||||
"name": "OpenAIChatCompletionClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "azure_endpoint",
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
"AzureOpenAI.Responses": {
|
||||
"package": "agent_framework.openai",
|
||||
"name": "OpenAIChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "azure_endpoint",
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
"Foundry": {
|
||||
"package": "agent_framework.foundry",
|
||||
"name": "FoundryChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "project_endpoint",
|
||||
"api_key_field": None,
|
||||
},
|
||||
"OpenAI.Chat": {
|
||||
"package": "agent_framework.openai",
|
||||
"name": "OpenAIChatCompletionClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "base_url",
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
"OpenAI.Responses": {
|
||||
"package": "agent_framework.openai",
|
||||
"name": "OpenAIChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "base_url",
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
"OpenAI": {
|
||||
"package": "agent_framework.openai",
|
||||
"name": "OpenAIChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "base_url",
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
"Foundry.Chat": {
|
||||
"package": "agent_framework.foundry",
|
||||
"name": "FoundryChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": "project_endpoint",
|
||||
"api_key_field": None,
|
||||
},
|
||||
"Anthropic.Chat": {
|
||||
"package": "agent_framework.anthropic",
|
||||
"name": "AnthropicChatClient",
|
||||
"model_field": "model",
|
||||
"endpoint_field": None,
|
||||
"api_key_field": "api_key",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class DeclarativeLoaderError(AgentException):
|
||||
"""Exception raised for errors in the declarative loader."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class ProviderLookupError(DeclarativeLoaderError):
|
||||
"""Exception raised for errors in provider type lookup."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
||||
class AgentFactory:
|
||||
"""Factory for creating Agent instances from declarative YAML definitions.
|
||||
|
||||
AgentFactory parses YAML agent definitions (PromptAgent kind) and creates
|
||||
configured Agent instances with the appropriate chat client, tools,
|
||||
and response format.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# Create agent from YAML file
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_yaml_path("agent.yaml")
|
||||
|
||||
# Run the agent
|
||||
async for event in agent.run("Hello!", stream=True):
|
||||
print(event)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# With pre-configured chat client
|
||||
client = OpenAIChatClient()
|
||||
factory = AgentFactory(client=client)
|
||||
agent = factory.create_agent_from_yaml_path("agent.yaml")
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# From inline YAML string
|
||||
yaml_content = '''
|
||||
kind: Prompt
|
||||
name: GreetingAgent
|
||||
instructions: You are a friendly assistant.
|
||||
model:
|
||||
id: gpt-4o
|
||||
provider: AzureOpenAI
|
||||
'''
|
||||
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_yaml(yaml_content)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
bindings: Mapping[str, Any] | None = None,
|
||||
connections: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
|
||||
default_provider: str = "Foundry",
|
||||
safe_mode: bool = True,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Create the agent factory.
|
||||
|
||||
Args:
|
||||
client: An optional SupportsChatGetResponse instance to use as a dependency.
|
||||
This will be passed to the Agent that gets created.
|
||||
If you need to create multiple agents with different chat clients,
|
||||
do not pass this and instead provide the chat client in the YAML definition.
|
||||
bindings: An optional dictionary of bindings to use when creating agents.
|
||||
connections: An optional dictionary of connections to resolve ReferenceConnections.
|
||||
client_kwargs: An optional dictionary of keyword arguments to pass to chat client constructor.
|
||||
additional_mappings: An optional dictionary to extend the provider type to object mapping.
|
||||
Should have the structure:
|
||||
|
||||
..code-block:: python
|
||||
|
||||
additional_mappings = {
|
||||
"Provider.ApiType": {
|
||||
"package": "package.name",
|
||||
"name": "ClassName",
|
||||
"model_field": "field_name_in_constructor",
|
||||
"endpoint_field": "endpoint_kwarg_name_or_null",
|
||||
"api_key_field": "api_key_kwarg_name_or_null",
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the
|
||||
model, "Provider" is also allowed.
|
||||
Package refers to which model needs to be imported, Name is the class name of the
|
||||
SupportsChatGetResponse implementation, and model_field is the name of the field in the
|
||||
constructor that accepts the model.id value.
|
||||
default_provider: The default provider used when model.provider is not specified,
|
||||
default is "Foundry", which uses the FoundryChatClient.
|
||||
safe_mode: Whether to run in safe mode, default is True.
|
||||
When safe_mode is True, environment variables are not accessible in the powerfx expressions.
|
||||
You can still use environment variables, but through the constructors of the classes.
|
||||
Which means you must make sure you are using the standard env variable names of the classes
|
||||
you are using and not custom ones and remove the powerfx statements that start with `=Env.`.
|
||||
Only when you trust the source of your yaml files, you can set safe_mode to False
|
||||
via the AgentFactory constructor.
|
||||
env_file_path: The path to the .env file to load environment variables from.
|
||||
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# Minimal initialization
|
||||
factory = AgentFactory()
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# With shared chat client
|
||||
client = OpenAIChatClient()
|
||||
factory = AgentFactory(
|
||||
client=client,
|
||||
env_file_path=".env",
|
||||
)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# With custom provider mappings
|
||||
factory = AgentFactory(
|
||||
additional_mappings={
|
||||
"CustomProvider.Chat": {
|
||||
"package": "my_package.clients",
|
||||
"name": "CustomChatClient",
|
||||
"model_field": "model",
|
||||
},
|
||||
},
|
||||
)
|
||||
"""
|
||||
self.client = client
|
||||
self.bindings = bindings
|
||||
self.connections = connections
|
||||
self.client_kwargs = client_kwargs or {}
|
||||
self.additional_mappings = additional_mappings or {}
|
||||
self.default_provider: str = default_provider
|
||||
self.safe_mode = safe_mode
|
||||
load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
|
||||
|
||||
def create_agent_from_yaml_path(self, yaml_path: str | Path) -> Agent:
|
||||
"""Create a Agent from a YAML file path.
|
||||
|
||||
This method does the following things:
|
||||
|
||||
1. Loads the YAML file into an AgentSchema object.
|
||||
2. Validates that the loaded object is a PromptAgent.
|
||||
3. Creates the appropriate ChatClient based on the model provider and apiType.
|
||||
4. Parses the tools, options, and response format from the PromptAgent.
|
||||
5. Creates and returns a Agent instance with the configured properties.
|
||||
|
||||
Args:
|
||||
yaml_path: Path to the YAML file representation of a PromptAgent.
|
||||
|
||||
Returns:
|
||||
The ``Agent`` instance created from the YAML file.
|
||||
|
||||
Raises:
|
||||
DeclarativeLoaderError: If the YAML does not represent a PromptAgent.
|
||||
ProviderLookupError: If the provider type is unknown or unsupported.
|
||||
ValueError: If a ReferenceConnection cannot be resolved.
|
||||
ModuleNotFoundError: If the required module for the provider type cannot be imported.
|
||||
AttributeError: If the required class for the provider type cannot be found in the module.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_yaml_path("agents/support_agent.yaml")
|
||||
|
||||
# Execute the agent
|
||||
async for event in agent.run("Help me with my order", stream=True):
|
||||
print(event)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pathlib import Path
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
# Using Path object for cross-platform compatibility
|
||||
agent_path = Path(__file__).parent / "agents" / "writer.yaml"
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_yaml_path(agent_path)
|
||||
"""
|
||||
if not isinstance(yaml_path, Path):
|
||||
yaml_path = Path(yaml_path)
|
||||
if not yaml_path.exists():
|
||||
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
|
||||
with open(yaml_path) as f:
|
||||
yaml_str = f.read()
|
||||
return self.create_agent_from_yaml(yaml_str)
|
||||
|
||||
def create_agent_from_yaml(self, yaml_str: str) -> Agent:
|
||||
"""Create a Agent from a YAML string.
|
||||
|
||||
This method does the following things:
|
||||
|
||||
1. Loads the YAML string into an AgentSchema object.
|
||||
2. Validates that the loaded object is a PromptAgent.
|
||||
3. Creates the appropriate ChatClient based on the model provider and apiType.
|
||||
4. Parses the tools, options, and response format from the PromptAgent.
|
||||
5. Creates and returns a Agent instance with the configured properties.
|
||||
|
||||
Args:
|
||||
yaml_str: YAML string representation of a PromptAgent.
|
||||
|
||||
Returns:
|
||||
The ``Agent`` instance created from the YAML string.
|
||||
|
||||
Raises:
|
||||
DeclarativeLoaderError: If the YAML does not represent a PromptAgent.
|
||||
ProviderLookupError: If the provider type is unknown or unsupported.
|
||||
ValueError: If a ReferenceConnection cannot be resolved.
|
||||
ModuleNotFoundError: If the required module for the provider type cannot be imported.
|
||||
AttributeError: If the required class for the provider type cannot be found in the module.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
yaml_content = '''
|
||||
kind: Prompt
|
||||
name: TranslationAgent
|
||||
description: Translates text between languages
|
||||
instructions: |
|
||||
You are a translation assistant.
|
||||
Translate user input to the requested language.
|
||||
model:
|
||||
id: gpt-4o
|
||||
provider: AzureOpenAI
|
||||
options:
|
||||
temperature: 0.3
|
||||
'''
|
||||
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_yaml(yaml_content)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Agent with structured output
|
||||
yaml_content = '''
|
||||
kind: Prompt
|
||||
name: SentimentAnalyzer
|
||||
instructions: Analyze the sentiment of the input text.
|
||||
model:
|
||||
id: gpt-4o
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
sentiment:
|
||||
type: string
|
||||
enum: [positive, negative, neutral]
|
||||
confidence:
|
||||
type: number
|
||||
'''
|
||||
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_yaml(yaml_content)
|
||||
"""
|
||||
return self.create_agent_from_dict(yaml.safe_load(yaml_str))
|
||||
|
||||
def create_agent_from_dict(self, agent_def: dict[str, Any]) -> Agent:
|
||||
"""Create a Agent from a dictionary definition.
|
||||
|
||||
This method does the following things:
|
||||
|
||||
1. Converts the dictionary into an AgentSchema object.
|
||||
2. Validates that the loaded object is a PromptAgent.
|
||||
3. Creates the appropriate ChatClient based on the model provider and apiType.
|
||||
4. Parses the tools, options, and response format from the PromptAgent.
|
||||
5. Creates and returns a Agent instance with the configured properties.
|
||||
|
||||
Args:
|
||||
agent_def: Dictionary representation of a PromptAgent.
|
||||
|
||||
Returns:
|
||||
The `Agent` instance created from the dictionary.
|
||||
|
||||
Raises:
|
||||
DeclarativeLoaderError: If the dictionary does not represent a PromptAgent.
|
||||
ProviderLookupError: If the provider type is unknown or unsupported.
|
||||
ValueError: If a ReferenceConnection cannot be resolved.
|
||||
ModuleNotFoundError: If the required module for the provider type cannot be imported.
|
||||
AttributeError: If the required class for the provider type cannot be found in the module.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
agent_def = {
|
||||
"kind": "Prompt",
|
||||
"name": "TranslationAgent",
|
||||
"description": "Translates text between languages",
|
||||
"instructions": "You are a translation assistant.",
|
||||
"model": {
|
||||
"id": "gpt-4o",
|
||||
"provider": "AzureOpenAI",
|
||||
},
|
||||
}
|
||||
|
||||
factory = AgentFactory()
|
||||
agent = factory.create_agent_from_dict(agent_def)
|
||||
"""
|
||||
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
|
||||
_safe_mode_context.set(self.safe_mode)
|
||||
prompt_agent = agent_schema_dispatch(agent_def)
|
||||
if not isinstance(prompt_agent, PromptAgent):
|
||||
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
|
||||
|
||||
# Step 1: Create the ChatClient
|
||||
client = self._get_client(prompt_agent)
|
||||
# Step 2: Get the chat options
|
||||
chat_options = self._parse_chat_options(prompt_agent.model)
|
||||
if tools := self._parse_tools(prompt_agent.tools):
|
||||
chat_options["tools"] = tools
|
||||
if output_schema := prompt_agent.outputSchema:
|
||||
chat_options["response_format"] = output_schema.to_json_schema()
|
||||
# Step 3: Create the agent instance
|
||||
return Agent(
|
||||
client=client,
|
||||
name=prompt_agent.name,
|
||||
description=prompt_agent.description,
|
||||
instructions=prompt_agent.instructions,
|
||||
default_options=chat_options, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
|
||||
"""Async version: Create a Agent from a YAML file path.
|
||||
|
||||
This is the async counterpart to ``create_agent_from_dict`` and is useful when
|
||||
the rest of your setup is already async.
|
||||
|
||||
Args:
|
||||
yaml_path: Path to the YAML file representation of a PromptAgent.
|
||||
|
||||
Returns:
|
||||
The ``Agent`` instance created from the YAML file.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
factory = AgentFactory(
|
||||
client_kwargs={"credential": credential},
|
||||
default_provider="Foundry",
|
||||
)
|
||||
agent = await factory.create_agent_from_yaml_path_async("agent.yaml")
|
||||
"""
|
||||
if not isinstance(yaml_path, Path):
|
||||
yaml_path = Path(yaml_path)
|
||||
if not yaml_path.exists():
|
||||
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
|
||||
yaml_str = yaml_path.read_text()
|
||||
return await self.create_agent_from_yaml_async(yaml_str)
|
||||
|
||||
async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
|
||||
"""Async version: Create a Agent from a YAML string.
|
||||
|
||||
Use this method when the surrounding call site is already async and you
|
||||
want to build an agent directly from YAML text.
|
||||
|
||||
Args:
|
||||
yaml_str: YAML string representation of a PromptAgent.
|
||||
|
||||
Returns:
|
||||
The ``Agent`` instance created from the YAML string.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
yaml_content = '''
|
||||
kind: Prompt
|
||||
name: MyAgent
|
||||
instructions: You are a helpful assistant.
|
||||
model:
|
||||
id: gpt-4o
|
||||
provider: Foundry
|
||||
'''
|
||||
|
||||
factory = AgentFactory(client_kwargs={"credential": credential})
|
||||
agent = await factory.create_agent_from_yaml_async(yaml_content)
|
||||
"""
|
||||
return await self.create_agent_from_dict_async(yaml.safe_load(yaml_str))
|
||||
|
||||
async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent:
|
||||
"""Async version: Create a Agent from a dictionary definition.
|
||||
|
||||
This is the async counterpart to ``create_agent_from_dict`` and is useful when
|
||||
the rest of your setup is already async.
|
||||
|
||||
Args:
|
||||
agent_def: Dictionary representation of a PromptAgent.
|
||||
|
||||
Returns:
|
||||
The ``Agent`` instance created from the dictionary.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import AgentFactory
|
||||
|
||||
agent_def = {
|
||||
"kind": "Prompt",
|
||||
"name": "MyAgent",
|
||||
"instructions": "You are a helpful assistant.",
|
||||
"model": {
|
||||
"id": "gpt-4o",
|
||||
"provider": "Foundry",
|
||||
},
|
||||
}
|
||||
|
||||
factory = AgentFactory(client_kwargs={"credential": credential})
|
||||
agent = await factory.create_agent_from_dict_async(agent_def)
|
||||
"""
|
||||
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
|
||||
_safe_mode_context.set(self.safe_mode)
|
||||
prompt_agent = agent_schema_dispatch(agent_def)
|
||||
if not isinstance(prompt_agent, PromptAgent):
|
||||
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
|
||||
|
||||
client = self._get_client(prompt_agent)
|
||||
chat_options = self._parse_chat_options(prompt_agent.model)
|
||||
if tools := self._parse_tools(prompt_agent.tools):
|
||||
chat_options["tools"] = tools
|
||||
if output_schema := prompt_agent.outputSchema:
|
||||
chat_options["response_format"] = output_schema.to_json_schema()
|
||||
return Agent(
|
||||
client=client,
|
||||
name=prompt_agent.name,
|
||||
description=prompt_agent.description,
|
||||
instructions=prompt_agent.instructions,
|
||||
default_options=chat_options, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent:
|
||||
"""Create an Agent through a provider object that exposes ``create_agent``.
|
||||
|
||||
This remains available as an internal escape hatch for provider-style custom mappings
|
||||
that return a fully constructed ``Agent`` rather than a chat client.
|
||||
"""
|
||||
module_name = mapping["package"]
|
||||
class_name = mapping["name"]
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
provider_class = getattr(module, class_name)
|
||||
|
||||
provider_kwargs: dict[str, Any] = {}
|
||||
provider_kwargs.update(self.client_kwargs)
|
||||
|
||||
endpoint_field = mapping.get("endpoint_field")
|
||||
api_key_field = mapping.get("api_key_field", "api_key")
|
||||
|
||||
if prompt_agent.model and prompt_agent.model.connection:
|
||||
match prompt_agent.model.connection:
|
||||
case ApiKeyConnection():
|
||||
if api_key_field:
|
||||
provider_kwargs[api_key_field] = prompt_agent.model.connection.apiKey
|
||||
if prompt_agent.model.connection.endpoint and endpoint_field:
|
||||
provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint
|
||||
case RemoteConnection() | AnonymousConnection():
|
||||
if prompt_agent.model.connection.endpoint and endpoint_field:
|
||||
provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint
|
||||
case ReferenceConnection():
|
||||
pass
|
||||
|
||||
provider = provider_class(**provider_kwargs)
|
||||
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None
|
||||
|
||||
default_options: dict[str, Any] | None = None
|
||||
if prompt_agent.outputSchema:
|
||||
default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()}
|
||||
|
||||
return cast(
|
||||
Agent,
|
||||
await provider.create_agent(
|
||||
name=prompt_agent.name,
|
||||
model=prompt_agent.model.id if prompt_agent.model else None,
|
||||
instructions=prompt_agent.instructions,
|
||||
description=prompt_agent.description,
|
||||
tools=tools,
|
||||
default_options=default_options,
|
||||
),
|
||||
)
|
||||
|
||||
def _get_client(self, prompt_agent: PromptAgent) -> SupportsChatGetResponse:
|
||||
"""Create the SupportsChatGetResponse instance based on the PromptAgent model."""
|
||||
if not prompt_agent.model:
|
||||
# if no model is defined, use the supplied client
|
||||
if self.client:
|
||||
return self.client
|
||||
raise DeclarativeLoaderError(
|
||||
"ChatClient must be provided to create agent from PromptAgent, "
|
||||
"alternatively define a model in the PromptAgent."
|
||||
)
|
||||
|
||||
mapping = self._retrieve_provider_configuration(prompt_agent.model)
|
||||
setup_dict: dict[str, Any] = {}
|
||||
setup_dict.update(self.client_kwargs)
|
||||
endpoint_field = mapping.get("endpoint_field")
|
||||
api_key_field = mapping.get("api_key_field", "api_key")
|
||||
|
||||
# parse connections
|
||||
if prompt_agent.model.connection:
|
||||
match prompt_agent.model.connection:
|
||||
case ApiKeyConnection():
|
||||
if api_key_field:
|
||||
setup_dict[api_key_field] = prompt_agent.model.connection.apiKey
|
||||
elif prompt_agent.model.connection.apiKey:
|
||||
raise DeclarativeLoaderError(
|
||||
f"{mapping['name']} does not support API key-based model connections."
|
||||
)
|
||||
if prompt_agent.model.connection.endpoint:
|
||||
if not endpoint_field:
|
||||
raise DeclarativeLoaderError(
|
||||
f"{mapping['name']} does not support endpoint-based model connections."
|
||||
)
|
||||
setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint
|
||||
case RemoteConnection() | AnonymousConnection():
|
||||
if prompt_agent.model.connection.endpoint:
|
||||
if not endpoint_field:
|
||||
raise DeclarativeLoaderError(
|
||||
f"{mapping['name']} does not support endpoint-based model connections."
|
||||
)
|
||||
setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint
|
||||
case ReferenceConnection():
|
||||
if not self.connections:
|
||||
raise ValueError("Connections must be provided to resolve ReferenceConnection")
|
||||
# find the referenced connection
|
||||
if prompt_agent.model.connection.name and (
|
||||
value := self.connections.get(prompt_agent.model.connection.name)
|
||||
):
|
||||
setup_dict[prompt_agent.model.connection.name] = value
|
||||
else:
|
||||
raise ValueError(
|
||||
f"ReferenceConnection with name {prompt_agent.model.connection.name} not found in provided "
|
||||
"connections."
|
||||
)
|
||||
|
||||
# Any client we create, needs a model.id
|
||||
if not prompt_agent.model.id:
|
||||
# if prompt_agent.model is defined, but no id, use the supplied client
|
||||
if self.client:
|
||||
return self.client
|
||||
# or raise, since we cannot create a client without a model
|
||||
raise DeclarativeLoaderError(
|
||||
"ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent."
|
||||
)
|
||||
# if provider is defined, use that, if possible with apiType, fallback to default_provider
|
||||
module_name = mapping["package"]
|
||||
class_name = mapping["name"]
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
agent_class = getattr(module, class_name)
|
||||
setup_dict[mapping["model_field"]] = prompt_agent.model.id
|
||||
return agent_class(**setup_dict)
|
||||
|
||||
def _parse_chat_options(self, model: Model | None) -> dict[str, Any]:
|
||||
"""Parse ModelOptions into chat options dictionary."""
|
||||
chat_options: dict[str, Any] = {}
|
||||
if not model or not model.options or not isinstance(model.options, ModelOptions):
|
||||
return chat_options
|
||||
options = model.options
|
||||
if options.frequencyPenalty is not None:
|
||||
chat_options["frequency_penalty"] = options.frequencyPenalty
|
||||
if options.presencePenalty is not None:
|
||||
chat_options["presence_penalty"] = options.presencePenalty
|
||||
if options.maxOutputTokens is not None:
|
||||
chat_options["max_tokens"] = options.maxOutputTokens
|
||||
if options.temperature is not None:
|
||||
chat_options["temperature"] = options.temperature
|
||||
if options.topP is not None:
|
||||
chat_options["top_p"] = options.topP
|
||||
if options.seed is not None:
|
||||
chat_options["seed"] = options.seed
|
||||
if options.stopSequences:
|
||||
chat_options["stop"] = options.stopSequences
|
||||
if options.allowMultipleToolCalls is not None:
|
||||
chat_options["allow_multiple_tool_calls"] = options.allowMultipleToolCalls
|
||||
if (chat_tool_mode := options.additionalProperties.pop("chatToolMode", None)) is not None:
|
||||
chat_options["tool_choice"] = chat_tool_mode
|
||||
if options.additionalProperties:
|
||||
chat_options["additional_chat_options"] = options.additionalProperties
|
||||
return chat_options
|
||||
|
||||
def _parse_tools(self, tools: list[Tool] | None) -> list[AFFunctionTool | dict[str, Any]] | None:
|
||||
"""Parse tool resources into AFFunctionTool instances or dict-based tools."""
|
||||
if not tools:
|
||||
return None
|
||||
return [self._parse_tool(tool_resource) for tool_resource in tools]
|
||||
|
||||
def _parse_tool(self, tool_resource: Tool) -> AFFunctionTool | dict[str, Any]:
|
||||
"""Parse a single tool resource into an AFFunctionTool instance."""
|
||||
match tool_resource:
|
||||
case FunctionTool():
|
||||
func: Callable[..., Any] | None = None
|
||||
if self.bindings and tool_resource.bindings:
|
||||
for binding in tool_resource.bindings:
|
||||
if binding.name and (func := self.bindings.get(binding.name)):
|
||||
break
|
||||
return AFFunctionTool(
|
||||
name=tool_resource.name, # type: ignore
|
||||
description=tool_resource.description, # type: ignore
|
||||
input_model=tool_resource.parameters.to_json_schema() if tool_resource.parameters else None,
|
||||
func=func,
|
||||
)
|
||||
case WebSearchTool():
|
||||
result: dict[str, Any] = {"type": "web_search_preview"}
|
||||
if tool_resource.description:
|
||||
result["description"] = tool_resource.description
|
||||
if tool_resource.options:
|
||||
result.update(tool_resource.options)
|
||||
return result
|
||||
case FileSearchTool():
|
||||
result = {
|
||||
"type": "file_search",
|
||||
"vector_store_ids": tool_resource.vectorStoreIds or [],
|
||||
}
|
||||
if tool_resource.maximumResultCount is not None:
|
||||
result["max_num_results"] = tool_resource.maximumResultCount
|
||||
if tool_resource.description:
|
||||
result["description"] = tool_resource.description
|
||||
if tool_resource.ranker is not None:
|
||||
result["ranker"] = tool_resource.ranker
|
||||
if tool_resource.scoreThreshold is not None:
|
||||
result["score_threshold"] = tool_resource.scoreThreshold
|
||||
if tool_resource.filters:
|
||||
result["filters"] = tool_resource.filters
|
||||
return result
|
||||
case CodeInterpreterTool():
|
||||
result = {"type": "code_interpreter"}
|
||||
if tool_resource.fileIds:
|
||||
result["file_ids"] = tool_resource.fileIds
|
||||
if tool_resource.description:
|
||||
result["description"] = tool_resource.description
|
||||
return result
|
||||
case McpTool():
|
||||
result = {
|
||||
"type": "mcp",
|
||||
"server_label": tool_resource.name.replace(" ", "_") if tool_resource.name else "",
|
||||
"server_url": str(tool_resource.url) if tool_resource.url else "",
|
||||
}
|
||||
if tool_resource.description:
|
||||
result["server_description"] = tool_resource.description
|
||||
if tool_resource.allowedTools:
|
||||
result["allowed_tools"] = list(tool_resource.allowedTools)
|
||||
|
||||
# Handle approval mode
|
||||
if tool_resource.approvalMode is not None:
|
||||
if tool_resource.approvalMode.kind == "always":
|
||||
result["require_approval"] = "always"
|
||||
elif tool_resource.approvalMode.kind == "never":
|
||||
result["require_approval"] = "never"
|
||||
elif isinstance(tool_resource.approvalMode, McpServerToolSpecifyApprovalMode):
|
||||
approval_config: dict[str, Any] = {}
|
||||
if tool_resource.approvalMode.alwaysRequireApprovalTools:
|
||||
approval_config["always"] = {
|
||||
"tool_names": list(tool_resource.approvalMode.alwaysRequireApprovalTools)
|
||||
}
|
||||
if tool_resource.approvalMode.neverRequireApprovalTools:
|
||||
approval_config["never"] = {
|
||||
"tool_names": list(tool_resource.approvalMode.neverRequireApprovalTools)
|
||||
}
|
||||
if approval_config:
|
||||
result["require_approval"] = approval_config
|
||||
|
||||
# Handle connection settings
|
||||
if tool_resource.connection is not None:
|
||||
match tool_resource.connection:
|
||||
case ApiKeyConnection():
|
||||
if tool_resource.connection.apiKey:
|
||||
result["headers"] = {"Authorization": f"Bearer {tool_resource.connection.apiKey}"}
|
||||
case RemoteConnection():
|
||||
result["project_connection_id"] = tool_resource.connection.name
|
||||
case ReferenceConnection():
|
||||
result["project_connection_id"] = tool_resource.connection.name
|
||||
case AnonymousConnection():
|
||||
pass
|
||||
case _:
|
||||
raise ValueError(f"Unsupported connection kind: {tool_resource.connection.kind}")
|
||||
|
||||
return result
|
||||
case _:
|
||||
raise ValueError(f"Unsupported tool kind: {tool_resource.kind}")
|
||||
|
||||
def _retrieve_provider_configuration(self, model: Model) -> ProviderTypeMapping:
|
||||
"""Retrieve the provider configuration based on the model's provider and apiType.
|
||||
|
||||
If only provider is specified, it will be used.
|
||||
If both provider and apiType are specified, both will be used.
|
||||
If neither is specified, the default_provider will be used.
|
||||
|
||||
Args:
|
||||
model: The Model instance containing provider and apiType information.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the package, name, and model_field for the provider.
|
||||
|
||||
Raises:
|
||||
ProviderLookupError: If the provider type is not supported or can't be found.
|
||||
"""
|
||||
class_lookup = (
|
||||
f"{model.provider}.{model.apiType}"
|
||||
if model.apiType
|
||||
else f"{model.provider}"
|
||||
if model.provider
|
||||
else self.default_provider
|
||||
)
|
||||
if class_lookup in self.additional_mappings:
|
||||
return self.additional_mappings[class_lookup]
|
||||
if class_lookup not in PROVIDER_TYPE_OBJECT_MAPPING:
|
||||
raise ProviderLookupError(f"Unsupported provider type: {class_lookup}")
|
||||
return PROVIDER_TYPE_OBJECT_MAPPING[class_lookup]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Declarative workflow support for agent-framework.
|
||||
|
||||
This module provides the ability to create executable Workflow objects from YAML definitions,
|
||||
enabling multi-agent orchestration patterns like Foreach, conditionals, and agent invocations.
|
||||
|
||||
Graph-based execution enables:
|
||||
- Checkpointing at action boundaries
|
||||
- Workflow visualization
|
||||
- Pause/resume capabilities
|
||||
- Full integration with the workflow runtime
|
||||
"""
|
||||
|
||||
from ._declarative_base import (
|
||||
DECLARATIVE_STATE_KEY,
|
||||
ActionComplete,
|
||||
ActionTrigger,
|
||||
ConversationData,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeMessage,
|
||||
DeclarativeStateData,
|
||||
DeclarativeWorkflowState,
|
||||
LoopControl,
|
||||
LoopIterationResult,
|
||||
)
|
||||
from ._declarative_builder import ALL_ACTION_EXECUTORS, DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeActionError, DeclarativeWorkflowError
|
||||
from ._executors_agents import (
|
||||
AGENT_ACTION_EXECUTORS,
|
||||
AGENT_REGISTRY_KEY,
|
||||
TOOL_REGISTRY_KEY,
|
||||
AgentExternalInputRequest,
|
||||
AgentExternalInputResponse,
|
||||
AgentResult,
|
||||
ExternalLoopState,
|
||||
InvokeAzureAgentExecutor,
|
||||
)
|
||||
from ._executors_basic import (
|
||||
BASIC_ACTION_EXECUTORS,
|
||||
ClearAllVariablesExecutor,
|
||||
CreateConversationExecutor,
|
||||
ResetVariableExecutor,
|
||||
SendActivityExecutor,
|
||||
SetMultipleVariablesExecutor,
|
||||
SetTextVariableExecutor,
|
||||
SetValueExecutor,
|
||||
SetVariableExecutor,
|
||||
)
|
||||
from ._executors_control_flow import (
|
||||
CONTROL_FLOW_EXECUTORS,
|
||||
BreakLoopExecutor,
|
||||
ContinueLoopExecutor,
|
||||
EndConversationExecutor,
|
||||
EndWorkflowExecutor,
|
||||
ForeachInitExecutor,
|
||||
ForeachNextExecutor,
|
||||
JoinExecutor,
|
||||
)
|
||||
from ._executors_external_input import (
|
||||
EXTERNAL_INPUT_EXECUTORS,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
QuestionExecutor,
|
||||
RequestExternalInputExecutor,
|
||||
)
|
||||
from ._executors_http import (
|
||||
HTTP_ACTION_EXECUTORS,
|
||||
HttpRequestActionExecutor,
|
||||
)
|
||||
from ._executors_mcp import (
|
||||
MCP_ACTION_EXECUTORS,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
)
|
||||
from ._executors_tools import (
|
||||
FUNCTION_TOOL_REGISTRY_KEY,
|
||||
TOOL_ACTION_EXECUTORS,
|
||||
BaseToolExecutor,
|
||||
InvokeFunctionToolExecutor,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
ToolInvocationResult,
|
||||
)
|
||||
from ._factory import WorkflowFactory
|
||||
from ._http_handler import (
|
||||
DefaultHttpRequestHandler,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
)
|
||||
from ._mcp_handler import (
|
||||
DefaultMCPToolHandler,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
)
|
||||
from ._state import WorkflowState
|
||||
|
||||
__all__ = [
|
||||
"AGENT_ACTION_EXECUTORS",
|
||||
"AGENT_REGISTRY_KEY",
|
||||
"ALL_ACTION_EXECUTORS",
|
||||
"BASIC_ACTION_EXECUTORS",
|
||||
"CONTROL_FLOW_EXECUTORS",
|
||||
"DECLARATIVE_STATE_KEY",
|
||||
"EXTERNAL_INPUT_EXECUTORS",
|
||||
"FUNCTION_TOOL_REGISTRY_KEY",
|
||||
"HTTP_ACTION_EXECUTORS",
|
||||
"MCP_ACTION_EXECUTORS",
|
||||
"TOOL_ACTION_EXECUTORS",
|
||||
"TOOL_REGISTRY_KEY",
|
||||
"ActionComplete",
|
||||
"ActionTrigger",
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentResult",
|
||||
"BaseToolExecutor",
|
||||
"BreakLoopExecutor",
|
||||
"ClearAllVariablesExecutor",
|
||||
"ContinueLoopExecutor",
|
||||
"ConversationData",
|
||||
"CreateConversationExecutor",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeActionExecutor",
|
||||
"DeclarativeMessage",
|
||||
"DeclarativeStateData",
|
||||
"DeclarativeWorkflowBuilder",
|
||||
"DeclarativeWorkflowError",
|
||||
"DeclarativeWorkflowState",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"EndConversationExecutor",
|
||||
"EndWorkflowExecutor",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"ExternalLoopState",
|
||||
"ForeachInitExecutor",
|
||||
"ForeachNextExecutor",
|
||||
"HttpRequestActionExecutor",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"InvokeAzureAgentExecutor",
|
||||
"InvokeFunctionToolExecutor",
|
||||
"InvokeMcpToolActionExecutor",
|
||||
"JoinExecutor",
|
||||
"LoopControl",
|
||||
"LoopIterationResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"QuestionExecutor",
|
||||
"RequestExternalInputExecutor",
|
||||
"ResetVariableExecutor",
|
||||
"SendActivityExecutor",
|
||||
"SetMultipleVariablesExecutor",
|
||||
"SetTextVariableExecutor",
|
||||
"SetValueExecutor",
|
||||
"SetVariableExecutor",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"ToolInvocationResult",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
+1226
File diff suppressed because it is too large
Load Diff
+1057
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Error types for declarative workflow executor modules.
|
||||
|
||||
This module exists so that executor modules and the builder (e.g.
|
||||
``_executors_http``, ``_declarative_builder``) can raise declarative-specific
|
||||
exceptions without importing from ``_factory``. ``_factory`` imports
|
||||
``_declarative_builder`` which imports the executor modules; pulling
|
||||
:class:`DeclarativeWorkflowError` from ``_factory`` into an executor or
|
||||
builder module would therefore introduce a circular import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_framework.exceptions import WorkflowException
|
||||
|
||||
|
||||
class DeclarativeWorkflowError(WorkflowException):
|
||||
"""Raised for build-time / factory-level declarative workflow errors.
|
||||
|
||||
Used for YAML parsing/validation issues, missing configuration (e.g. an
|
||||
HTTP request handler not supplied for a workflow that contains an
|
||||
``HttpRequestAction``), and other errors detected before workflow
|
||||
execution begins.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeclarativeActionError(WorkflowException):
|
||||
"""Raised when a declarative action fails at run time.
|
||||
|
||||
Used by executor modules for runtime failures (e.g. transport errors,
|
||||
non-2xx responses from :class:`HttpRequestActionExecutor`). Build-time and
|
||||
factory-level errors continue to use :class:`DeclarativeWorkflowError`.
|
||||
"""
|
||||
|
||||
pass
|
||||
+1025
File diff suppressed because it is too large
Load Diff
+574
@@ -0,0 +1,574 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Basic action executors for the graph-based declarative workflow system.
|
||||
|
||||
These executors handle simple actions like SetValue, SendActivity, etc.
|
||||
Each action becomes a node in the workflow graph.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
)
|
||||
|
||||
|
||||
def _get_variable_path(action_def: dict[str, Any], key: str = "variable") -> str | None:
|
||||
"""Extract variable path from action definition.
|
||||
|
||||
Supports .NET style (variable: Local.VarName) and nested object style (variable: {path: ...}).
|
||||
"""
|
||||
variable = action_def.get(key)
|
||||
if isinstance(variable, str):
|
||||
return variable
|
||||
if isinstance(variable, Mapping):
|
||||
path = variable.get("path") # type: ignore[reportUnknownVariableType]
|
||||
return path if isinstance(path, str) else None
|
||||
|
||||
fallback_path = action_def.get("path")
|
||||
return fallback_path if isinstance(fallback_path, str) else None
|
||||
|
||||
|
||||
class SetValueExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the SetValue action.
|
||||
|
||||
Sets a value in the workflow state at a specified path.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the SetValue action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = self._action_def.get("path")
|
||||
value = self._action_def.get("value")
|
||||
|
||||
if path:
|
||||
# Evaluate value if it's an expression
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
state.set(path, evaluated_value)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class SetVariableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the SetVariable action (.NET style naming)."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the SetVariable action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = _get_variable_path(self._action_def)
|
||||
value = self._action_def.get("value")
|
||||
|
||||
if path:
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
state.set(path, evaluated_value)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class CreateConversationExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the CreateConversation action.
|
||||
|
||||
Generates a unique conversation ID and initialises a conversation entry
|
||||
in ``System.conversations``. The generated ID is stored at the state
|
||||
path specified by the ``conversationId`` parameter (if provided).
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the CreateConversation action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
generated_id = str(uuid.uuid4())
|
||||
|
||||
# Store the generated ID at the requested path (e.g. "Local.myConvId")
|
||||
conversation_id_path = _get_variable_path(self._action_def, "conversationId")
|
||||
if conversation_id_path:
|
||||
state.set(conversation_id_path, generated_id)
|
||||
|
||||
# Initialise the conversation entry in System.conversations
|
||||
conversations: dict[str, Any] = state.get("System.conversations") or {}
|
||||
conversations[generated_id] = {
|
||||
"id": generated_id,
|
||||
"messages": [],
|
||||
}
|
||||
state.set("System.conversations", conversations)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class SetTextVariableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the SetTextVariable action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the SetTextVariable action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = _get_variable_path(self._action_def)
|
||||
text = self._action_def.get("text", "")
|
||||
|
||||
if path:
|
||||
evaluated_text = state.eval_if_expression(text)
|
||||
state.set(path, str(evaluated_text) if evaluated_text is not None else "")
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the SetMultipleVariables action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the SetMultipleVariables action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
assignments = cast(
|
||||
list[Mapping[str, Any]],
|
||||
self._action_def.get("assignments") if isinstance(self._action_def.get("assignments"), list) else [],
|
||||
)
|
||||
for assignment in assignments:
|
||||
if not isinstance(assignment, Mapping):
|
||||
continue
|
||||
variable = assignment.get("variable")
|
||||
path: str | None
|
||||
if isinstance(variable, str):
|
||||
path = variable
|
||||
elif isinstance(variable, Mapping):
|
||||
path_value = variable.get("path") # type: ignore[reportUnknownMemberType]
|
||||
path = path_value if isinstance(path_value, str) else None
|
||||
else:
|
||||
fallback_path = assignment.get("path")
|
||||
path = fallback_path if isinstance(fallback_path, str) else None
|
||||
value = assignment.get("value")
|
||||
if path:
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
state.set(path, evaluated_value)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ResetVariableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ResetVariable action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the ResetVariable action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = _get_variable_path(self._action_def)
|
||||
|
||||
if path:
|
||||
# Reset to None/empty
|
||||
state.set(path, None)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ClearAllVariablesExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ClearAllVariables action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the ClearAllVariables action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Get state data and clear Local variables
|
||||
state_data = state.get_state_data()
|
||||
state_data["Local"] = {}
|
||||
state.set_state_data(state_data)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class SendActivityExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the SendActivity action.
|
||||
|
||||
Sends a text message or activity as workflow output.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Handle the SendActivity action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
activity = self._action_def.get("activity", "")
|
||||
|
||||
# Activity can be a string directly or a dict with a "text" field
|
||||
if isinstance(activity, Mapping):
|
||||
text: Any = activity.get("text", "") # type: ignore[reportUnknownMemberType]
|
||||
else:
|
||||
text = activity
|
||||
|
||||
if isinstance(text, str):
|
||||
# First evaluate any =expression syntax
|
||||
text = state.eval_if_expression(text)
|
||||
# Then interpolate any {Variable.Path} template syntax
|
||||
if isinstance(text, str):
|
||||
text = state.interpolate_string(text)
|
||||
|
||||
# Yield the text as workflow output
|
||||
if text:
|
||||
await ctx.yield_output(str(text)) # type: ignore[reportUnknownArgumentType]
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class EditTableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the EditTable action.
|
||||
|
||||
Performs operations on a table (list) variable such as add, remove, or clear.
|
||||
This is equivalent to the .NET EditTable action.
|
||||
|
||||
YAML example:
|
||||
- kind: EditTable
|
||||
table: Local.Items
|
||||
operation: add # add, remove, clear
|
||||
value: =Local.NewItem
|
||||
index: 0 # optional, for insert at position
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the EditTable action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
|
||||
operation = self._action_def.get("operation", "add").lower()
|
||||
value = self._action_def.get("value")
|
||||
index = self._action_def.get("index")
|
||||
|
||||
if table_path:
|
||||
# Get current table value
|
||||
current_table_value = state.get(table_path)
|
||||
current_table: list[Any]
|
||||
if current_table_value is None:
|
||||
current_table = []
|
||||
elif isinstance(current_table_value, list):
|
||||
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
|
||||
else:
|
||||
current_table = [current_table_value]
|
||||
|
||||
if operation == "add" or operation == "insert":
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
if index is not None:
|
||||
evaluated_index = state.eval_if_expression(index)
|
||||
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
|
||||
current_table.insert(idx, evaluated_value)
|
||||
else:
|
||||
current_table.append(evaluated_value)
|
||||
|
||||
elif operation == "remove":
|
||||
if value is not None:
|
||||
# Remove by value
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
if evaluated_value in current_table:
|
||||
current_table.remove(evaluated_value)
|
||||
elif index is not None:
|
||||
# Remove by index
|
||||
evaluated_index = state.eval_if_expression(index)
|
||||
idx = int(evaluated_index) if evaluated_index is not None else -1
|
||||
if 0 <= idx < len(current_table):
|
||||
current_table.pop(idx)
|
||||
|
||||
elif operation == "clear":
|
||||
current_table = []
|
||||
|
||||
elif operation == "set" or operation == "update":
|
||||
# Update item at index
|
||||
if index is not None:
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
evaluated_index = state.eval_if_expression(index)
|
||||
idx = int(evaluated_index) if evaluated_index is not None else 0
|
||||
if 0 <= idx < len(current_table):
|
||||
current_table[idx] = evaluated_value
|
||||
|
||||
state.set(table_path, current_table)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class EditTableV2Executor(DeclarativeActionExecutor):
|
||||
"""Executor for the EditTableV2 action.
|
||||
|
||||
Enhanced table editing with more operations and better record support.
|
||||
This is equivalent to the .NET EditTableV2 action.
|
||||
|
||||
YAML example:
|
||||
- kind: EditTableV2
|
||||
table: Local.Records
|
||||
operation: addOrUpdate # add, remove, clear, addOrUpdate, filter
|
||||
item: =Local.NewRecord
|
||||
key: id # for addOrUpdate, the field to match on
|
||||
condition: =item.status = "active" # for filter operation
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the EditTableV2 action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
|
||||
operation = self._action_def.get("operation", "add").lower()
|
||||
item = self._action_def.get("item") or self._action_def.get("value")
|
||||
key_field = self._action_def.get("key")
|
||||
index = self._action_def.get("index")
|
||||
|
||||
if table_path:
|
||||
# Get current table value
|
||||
current_table_value = state.get(table_path)
|
||||
current_table: list[Any]
|
||||
if current_table_value is None:
|
||||
current_table = []
|
||||
elif isinstance(current_table_value, list):
|
||||
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
|
||||
else:
|
||||
current_table = [current_table_value]
|
||||
|
||||
if operation == "add":
|
||||
evaluated_item = state.eval_if_expression(item)
|
||||
if index is not None:
|
||||
evaluated_index = state.eval_if_expression(index)
|
||||
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
|
||||
current_table.insert(idx, evaluated_item)
|
||||
else:
|
||||
current_table.append(evaluated_item)
|
||||
|
||||
elif operation == "remove":
|
||||
if item is not None:
|
||||
evaluated_item = state.eval_if_expression(item)
|
||||
if key_field and isinstance(evaluated_item, dict):
|
||||
# Remove by key match
|
||||
evaluated_item_dict = cast(dict[str, Any], evaluated_item)
|
||||
key_value = evaluated_item_dict.get(key_field)
|
||||
current_table = [
|
||||
r
|
||||
for r in current_table
|
||||
if not (isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value)
|
||||
]
|
||||
elif evaluated_item in current_table:
|
||||
current_table.remove(evaluated_item)
|
||||
elif index is not None:
|
||||
evaluated_index = state.eval_if_expression(index)
|
||||
idx = int(evaluated_index) if evaluated_index is not None else -1
|
||||
if 0 <= idx < len(current_table):
|
||||
current_table.pop(idx)
|
||||
|
||||
elif operation == "clear":
|
||||
current_table = []
|
||||
|
||||
elif operation == "addorupdate":
|
||||
evaluated_item = state.eval_if_expression(item)
|
||||
if key_field and isinstance(evaluated_item, dict):
|
||||
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
|
||||
# Find existing item with same key
|
||||
found_idx = -1
|
||||
for i, r in enumerate(current_table):
|
||||
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
|
||||
found_idx = i
|
||||
break
|
||||
if found_idx >= 0:
|
||||
# Update existing
|
||||
current_table[found_idx] = evaluated_item
|
||||
else:
|
||||
# Add new
|
||||
current_table.append(evaluated_item)
|
||||
else:
|
||||
# No key field - just add
|
||||
current_table.append(evaluated_item)
|
||||
|
||||
elif operation == "update":
|
||||
evaluated_item = state.eval_if_expression(item)
|
||||
if index is not None:
|
||||
evaluated_index = state.eval_if_expression(index)
|
||||
idx = int(evaluated_index) if evaluated_index is not None else 0
|
||||
if 0 <= idx < len(current_table):
|
||||
current_table[idx] = evaluated_item
|
||||
elif key_field and isinstance(evaluated_item, dict):
|
||||
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
|
||||
for i, r in enumerate(current_table):
|
||||
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
|
||||
current_table[i] = evaluated_item
|
||||
break
|
||||
|
||||
state.set(table_path, current_table)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ParseValueExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ParseValue action.
|
||||
|
||||
Parses a value expression and optionally converts it to a target type.
|
||||
This is equivalent to the .NET ParseValue action.
|
||||
|
||||
YAML example:
|
||||
- kind: ParseValue
|
||||
variable: Local.ParsedData
|
||||
value: =System.LastMessage.Text
|
||||
valueType: object # optional: string, number, boolean, object, array
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the ParseValue action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = _get_variable_path(self._action_def)
|
||||
value = self._action_def.get("value")
|
||||
value_type = self._action_def.get("valueType")
|
||||
|
||||
if path and value is not None:
|
||||
# Evaluate the value expression
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
|
||||
# Convert to target type if specified
|
||||
if value_type:
|
||||
evaluated_value = self._convert_to_type(evaluated_value, value_type)
|
||||
|
||||
state.set(path, evaluated_value)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
def _convert_to_type(self, value: Any, target_type: str) -> Any:
|
||||
"""Convert a value to the specified target type.
|
||||
|
||||
Args:
|
||||
value: The value to convert
|
||||
target_type: Target type (string, number, boolean, object, array)
|
||||
|
||||
Returns:
|
||||
The converted value
|
||||
"""
|
||||
import json
|
||||
|
||||
target_type = target_type.lower()
|
||||
|
||||
if target_type == "string":
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
if target_type in ("number", "int", "integer", "float", "decimal"):
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, str):
|
||||
# Try to parse as number
|
||||
try:
|
||||
if "." in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return 0
|
||||
return float(value) if isinstance(value, (int, float)) else 0
|
||||
|
||||
if target_type in ("boolean", "bool"):
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "yes", "1", "on")
|
||||
return bool(value)
|
||||
|
||||
if target_type in ("object", "record"):
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return cast(dict[str, Any], value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return cast(dict[str, Any], parsed)
|
||||
return {"value": parsed}
|
||||
except json.JSONDecodeError:
|
||||
return {"value": value}
|
||||
return {"value": value}
|
||||
|
||||
if target_type in ("array", "table", "list"):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return cast(list[Any], value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
return cast(list[Any], parsed)
|
||||
return [parsed]
|
||||
except json.JSONDecodeError:
|
||||
return [value]
|
||||
return [value]
|
||||
|
||||
# Unknown type - return as-is
|
||||
return value
|
||||
|
||||
|
||||
# Mapping of action kinds to executor classes
|
||||
BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"CreateConversation": CreateConversationExecutor,
|
||||
"SetValue": SetValueExecutor,
|
||||
"SetVariable": SetVariableExecutor,
|
||||
"SetTextVariable": SetTextVariableExecutor,
|
||||
"SetMultipleVariables": SetMultipleVariablesExecutor,
|
||||
"ResetVariable": ResetVariableExecutor,
|
||||
"ClearAllVariables": ClearAllVariablesExecutor,
|
||||
"SendActivity": SendActivityExecutor,
|
||||
"ParseValue": ParseValueExecutor,
|
||||
"EditTable": EditTableExecutor,
|
||||
"EditTableV2": EditTableV2Executor,
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Control flow executors for the graph-based declarative workflow system.
|
||||
|
||||
Control flow in the graph-based system is handled differently than the interpreter:
|
||||
- If/ConditionGroup: Condition evaluation happens in a dedicated evaluator executor that
|
||||
returns a ConditionResult with the first-matching branch index. Edge conditions
|
||||
then check the branch_index to route to the correct branch. This ensures only
|
||||
one branch executes (first-match semantics), matching the interpreter behavior.
|
||||
- Foreach: Loop iteration state managed in State + loop edges
|
||||
- Goto: Edge to target action (handled by builder)
|
||||
- Break/Continue: Special signals for loop control
|
||||
|
||||
The key insight is that control flow becomes GRAPH STRUCTURE, not executor logic.
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
ActionTrigger,
|
||||
ConditionResult,
|
||||
DeclarativeActionExecutor,
|
||||
LoopControl,
|
||||
LoopIterationResult,
|
||||
)
|
||||
|
||||
# Keys for loop state in State
|
||||
LOOP_STATE_KEY = "_declarative_loop_state"
|
||||
|
||||
# Index value indicating the else/default branch
|
||||
ELSE_BRANCH_INDEX = -1
|
||||
|
||||
|
||||
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates conditions for ConditionGroup and outputs the first-matching branch.
|
||||
|
||||
This executor implements first-match semantics by evaluating conditions sequentially
|
||||
and outputting a ConditionResult with the index of the first matching branch.
|
||||
Edge conditions downstream check this index to route to the correct branch.
|
||||
|
||||
This mirrors .NET's ConditionGroupExecutor.ExecuteAsync which returns the step ID
|
||||
of the first matching condition.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
conditions: list[dict[str, Any]],
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize the condition evaluator.
|
||||
|
||||
Args:
|
||||
action_def: The ConditionGroup action definition
|
||||
conditions: List of condition items, each with 'condition' and optional 'id'
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._conditions = conditions
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ConditionResult],
|
||||
) -> None:
|
||||
"""Evaluate conditions and output the first matching branch index."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Evaluate conditions sequentially - first match wins
|
||||
for index, cond_item in enumerate(self._conditions):
|
||||
condition_expr = cond_item.get("condition")
|
||||
if condition_expr is None:
|
||||
continue
|
||||
|
||||
# Normalize boolean conditions
|
||||
if condition_expr is True:
|
||||
condition_expr = "=true"
|
||||
elif condition_expr is False:
|
||||
condition_expr = "=false"
|
||||
elif isinstance(condition_expr, str) and not condition_expr.startswith("="):
|
||||
condition_expr = f"={condition_expr}"
|
||||
|
||||
result = state.eval(condition_expr)
|
||||
if bool(result):
|
||||
# First matching condition found
|
||||
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=result))
|
||||
return
|
||||
|
||||
# No condition matched - use else/default branch
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
|
||||
|
||||
class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates a single If condition and outputs a ConditionResult.
|
||||
|
||||
This is simpler than ConditionGroupEvaluator - just evaluates one condition
|
||||
and outputs branch_index=0 (then) or branch_index=-1 (else).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
condition_expr: str,
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize the if condition evaluator.
|
||||
|
||||
Args:
|
||||
action_def: The If action definition
|
||||
condition_expr: The condition expression to evaluate
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._condition_expr = condition_expr
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ConditionResult],
|
||||
) -> None:
|
||||
"""Evaluate the condition and output the result."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
result = state.eval(self._condition_expr)
|
||||
is_truthy = bool(result)
|
||||
|
||||
if is_truthy:
|
||||
await ctx.send_message(ConditionResult(matched=True, branch_index=0, value=result))
|
||||
else:
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX, value=result))
|
||||
|
||||
|
||||
class ForeachInitExecutor(DeclarativeActionExecutor):
|
||||
"""Initializes a foreach loop.
|
||||
|
||||
Sets up the loop state in State and determines if there are items.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[LoopIterationResult],
|
||||
) -> None:
|
||||
"""Initialize the loop and check for first item."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
items_expr = self._action_def.get("source")
|
||||
items_raw: Any = state.eval_if_expression(items_expr) or []
|
||||
|
||||
items: list[Any]
|
||||
items = (list(items_raw) if items_raw else []) if not isinstance(items_raw, (list, tuple)) else list(items_raw) # type: ignore
|
||||
|
||||
loop_id = self.id
|
||||
|
||||
# Store loop state
|
||||
state_data = state.get_state_data()
|
||||
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).setdefault(LOOP_STATE_KEY, {})
|
||||
loop_states[loop_id] = {
|
||||
"items": items,
|
||||
"index": 0,
|
||||
"length": len(items),
|
||||
}
|
||||
state.set_state_data(state_data)
|
||||
|
||||
if items:
|
||||
# Bind the current item and (when requested) the index under the Local scope.
|
||||
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
||||
index_var = (
|
||||
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
||||
)
|
||||
|
||||
state.set(item_var, items[0])
|
||||
if index_var:
|
||||
state.set(index_var, 0)
|
||||
|
||||
await ctx.send_message(LoopIterationResult(has_next=True, current_item=items[0], current_index=0))
|
||||
else:
|
||||
await ctx.send_message(LoopIterationResult(has_next=False))
|
||||
|
||||
|
||||
class ForeachNextExecutor(DeclarativeActionExecutor):
|
||||
"""Advances to the next item in a foreach loop.
|
||||
|
||||
This executor is triggered after the loop body completes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
init_executor_id: str,
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize with reference to the init executor.
|
||||
|
||||
Args:
|
||||
action_def: The Foreach action definition
|
||||
init_executor_id: ID of the corresponding ForeachInitExecutor
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._init_executor_id = init_executor_id
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[LoopIterationResult],
|
||||
) -> None:
|
||||
"""Advance to next item and send result."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
loop_id = self._init_executor_id
|
||||
|
||||
# Get loop state
|
||||
state_data = state.get_state_data()
|
||||
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
|
||||
loop_state = loop_states.get(loop_id)
|
||||
|
||||
if not loop_state:
|
||||
# No loop state - shouldn't happen but handle gracefully
|
||||
await ctx.send_message(LoopIterationResult(has_next=False))
|
||||
return
|
||||
|
||||
items = loop_state["items"]
|
||||
current_index = loop_state["index"] + 1
|
||||
|
||||
if current_index < len(items):
|
||||
# Update loop state
|
||||
loop_state["index"] = current_index
|
||||
state.set_state_data(state_data)
|
||||
|
||||
# Rebind the current item and (when requested) the index under the Local scope.
|
||||
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
||||
index_var = (
|
||||
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
||||
)
|
||||
|
||||
state.set(item_var, items[current_index])
|
||||
if index_var:
|
||||
state.set(index_var, current_index)
|
||||
|
||||
await ctx.send_message(
|
||||
LoopIterationResult(has_next=True, current_item=items[current_index], current_index=current_index)
|
||||
)
|
||||
else:
|
||||
# Loop complete - clean up
|
||||
loop_states_dict = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
|
||||
if loop_id in loop_states_dict:
|
||||
del loop_states_dict[loop_id]
|
||||
state.set_state_data(state_data)
|
||||
|
||||
await ctx.send_message(LoopIterationResult(has_next=False))
|
||||
|
||||
@handler
|
||||
async def handle_loop_control(
|
||||
self,
|
||||
control: LoopControl,
|
||||
ctx: WorkflowContext[LoopIterationResult],
|
||||
) -> None:
|
||||
"""Handle break/continue signals."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
if control.action == "break":
|
||||
# Clean up loop state and signal done
|
||||
state_data = state.get_state_data()
|
||||
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
|
||||
if self._init_executor_id in loop_states:
|
||||
del loop_states[self._init_executor_id]
|
||||
state.set_state_data(state_data)
|
||||
|
||||
await ctx.send_message(LoopIterationResult(has_next=False))
|
||||
|
||||
elif control.action == "continue":
|
||||
# Just advance to next iteration
|
||||
await self.handle_action(ActionTrigger(), ctx)
|
||||
|
||||
|
||||
class BreakLoopExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for BreakLoop action.
|
||||
|
||||
Sends a LoopControl signal to break out of the enclosing loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
loop_next_executor_id: str,
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize with reference to the loop's next executor.
|
||||
|
||||
Args:
|
||||
action_def: The action definition
|
||||
loop_next_executor_id: ID of the ForeachNextExecutor to signal
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._loop_next_executor_id = loop_next_executor_id
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[LoopControl],
|
||||
) -> None:
|
||||
"""Send break signal to the loop."""
|
||||
await ctx.send_message(LoopControl(action="break"))
|
||||
|
||||
|
||||
class ContinueLoopExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for ContinueLoop action.
|
||||
|
||||
Sends a LoopControl signal to continue to next iteration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
loop_next_executor_id: str,
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize with reference to the loop's next executor.
|
||||
|
||||
Args:
|
||||
action_def: The action definition
|
||||
loop_next_executor_id: ID of the ForeachNextExecutor to signal
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._loop_next_executor_id = loop_next_executor_id
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[LoopControl],
|
||||
) -> None:
|
||||
"""Send continue signal to the loop."""
|
||||
await ctx.send_message(LoopControl(action="continue"))
|
||||
|
||||
|
||||
class EndWorkflowExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for EndWorkflow/EndDialog action.
|
||||
|
||||
This executor simply doesn't send any message, causing the workflow
|
||||
to terminate at this point.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""End the workflow by not sending any continuation message."""
|
||||
# Don't send ActionComplete - workflow ends here
|
||||
pass
|
||||
|
||||
|
||||
class EndConversationExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for EndConversation action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""End the conversation."""
|
||||
# For now, just don't continue
|
||||
# In a full implementation, this would signal to close the conversation
|
||||
pass
|
||||
|
||||
|
||||
# Passthrough executor for joining control flow branches
|
||||
class JoinExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that joins multiple branches back together.
|
||||
|
||||
Used after If/ConditionGroup to merge control flow back to a single path.
|
||||
Also used as passthrough nodes for else/default branches.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: dict[str, Any]
|
||||
| str
|
||||
| list[Message]
|
||||
| ActionTrigger
|
||||
| ActionComplete
|
||||
| ConditionResult
|
||||
| LoopIterationResult,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Simply pass through to continue the workflow."""
|
||||
await self._ensure_state_initialized(ctx, trigger)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class CancelDialogExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for CancelDialog action.
|
||||
|
||||
Cancels the current dialog/workflow, equivalent to .NET CancelDialog.
|
||||
This terminates execution similarly to EndWorkflow.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Cancel the current dialog/workflow."""
|
||||
# CancelDialog terminates execution without continuing
|
||||
# Similar to EndWorkflow but semantically different (cancellation vs completion)
|
||||
pass
|
||||
|
||||
|
||||
class CancelAllDialogsExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for CancelAllDialogs action.
|
||||
|
||||
Cancels all dialogs in the execution stack, equivalent to .NET CancelAllDialogs.
|
||||
This terminates the entire workflow execution.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Cancel all dialogs/workflows."""
|
||||
# CancelAllDialogs terminates all execution
|
||||
pass
|
||||
|
||||
|
||||
# Mapping of control flow action kinds to executor classes
|
||||
# Note: Most control flow is handled by the builder creating graph structure,
|
||||
# these are the executors that are part of that structure
|
||||
CONTROL_FLOW_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"EndWorkflow": EndWorkflowExecutor,
|
||||
"EndDialog": EndWorkflowExecutor,
|
||||
"EndConversation": EndConversationExecutor,
|
||||
"CancelDialog": CancelDialogExecutor,
|
||||
"CancelAllDialogs": CancelAllDialogsExecutor,
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""External input executors for declarative workflows.
|
||||
|
||||
These executors handle interactions that require external input (user questions
|
||||
and external integrations), using the request_info pattern to pause the workflow
|
||||
and wait for responses.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
)
|
||||
|
||||
|
||||
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
|
||||
"""Return the prompt text from an action definition.
|
||||
|
||||
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
|
||||
string under ``primary_key``, or a top-level ``fallback_key`` value.
|
||||
"""
|
||||
match action_def.get(primary_key):
|
||||
case {"text": text}:
|
||||
return text
|
||||
case str() as text:
|
||||
return text
|
||||
case _:
|
||||
return action_def.get(fallback_key, "")
|
||||
|
||||
|
||||
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
|
||||
"""Return the state path where the action result should be written.
|
||||
|
||||
Looks at ``variable``, then ``output.property``, then top-level
|
||||
``property``, falling back to ``default``.
|
||||
"""
|
||||
output = action_def.get("output")
|
||||
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
|
||||
return action_def.get("variable") or nested or action_def.get("property") or default
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalInputRequest:
|
||||
"""Request for external input (triggers workflow pause).
|
||||
|
||||
Aligns with .NET ExternalInputRequest pattern. Used by Question and
|
||||
RequestExternalInput executors to signal that user input is needed.
|
||||
The workflow will pause via request_info and wait for an ExternalInputResponse.
|
||||
|
||||
Attributes:
|
||||
request_id: Unique identifier for this request.
|
||||
message: The prompt or question to display to the user.
|
||||
request_type: A free-form discriminator describing the kind of input
|
||||
being requested. ``QuestionExecutor`` emits ``"question"`` and
|
||||
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
|
||||
may supply any other string via the ``requestType`` field on a
|
||||
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
|
||||
propagated unchanged.
|
||||
metadata: Additional context (choices, output_property, timeout, etc.).
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
message: str
|
||||
request_type: str = "external"
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalInputResponse:
|
||||
"""Response to an ExternalInputRequest.
|
||||
|
||||
Provided by the caller to resume workflow execution with user input.
|
||||
|
||||
Attributes:
|
||||
user_input: The user's text response.
|
||||
value: Optional typed value (e.g., bool for confirmations, selected choice).
|
||||
"""
|
||||
|
||||
user_input: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
class QuestionExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that asks the user a question and waits for a response.
|
||||
|
||||
Uses the request_info pattern to pause execution until the user provides an answer.
|
||||
The response is stored in workflow state at the configured output property.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Ask the question and wait for a response."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
|
||||
output_property = _get_output_path(self._action_def, default="Local.answer")
|
||||
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
|
||||
choices = self._action_def.get("choices", [])
|
||||
allow_free_text = self._action_def.get("allowFreeText", True)
|
||||
|
||||
evaluated_question = state.eval_if_expression(question_text)
|
||||
|
||||
# Build choices metadata
|
||||
choices_data: list[dict[str, str]] | None = None
|
||||
if choices:
|
||||
choices_data = []
|
||||
for c in choices:
|
||||
if isinstance(c, dict):
|
||||
c_dict: dict[str, Any] = dict(c) # type: ignore[arg-type]
|
||||
choices_data.append({
|
||||
"value": c_dict.get("value", ""),
|
||||
"label": c_dict.get("label") or c_dict.get("value", ""),
|
||||
})
|
||||
else:
|
||||
choices_data.append({"value": str(c), "label": str(c)})
|
||||
|
||||
# Store output property in shared state for response handler
|
||||
ctx.state.set("_question_output_property", output_property)
|
||||
ctx.state.set("_question_default_value", default_value)
|
||||
|
||||
# Request external input - workflow pauses here
|
||||
await ctx.request_info(
|
||||
ExternalInputRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
message=str(evaluated_question),
|
||||
request_type="question",
|
||||
metadata={
|
||||
"output_property": output_property,
|
||||
"choices": choices_data,
|
||||
"allow_free_text": allow_free_text,
|
||||
"default_value": default_value,
|
||||
},
|
||||
),
|
||||
ExternalInputResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: ExternalInputRequest,
|
||||
response: ExternalInputResponse,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the user's response to the question."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
output_property = original_request.metadata.get("output_property", "Local.answer")
|
||||
answer = response.value if response.value is not None else response.user_input
|
||||
|
||||
if output_property:
|
||||
state.set(output_property, answer)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that requests external input/approval.
|
||||
|
||||
Used for complex external integrations beyond simple questions,
|
||||
such as approval workflows, document uploads, or external system integrations.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Request external input."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
|
||||
output_property = _get_output_path(self._action_def, default="Local.externalInput")
|
||||
default_value = self._action_def.get("default")
|
||||
|
||||
request_type = self._action_def.get("requestType", "external")
|
||||
timeout_seconds = self._action_def.get("timeout")
|
||||
required_fields = self._action_def.get("requiredFields", [])
|
||||
metadata = self._action_def.get("metadata", {})
|
||||
|
||||
evaluated_message = state.eval_if_expression(message)
|
||||
|
||||
# Build request metadata
|
||||
request_metadata: dict[str, Any] = {
|
||||
**metadata,
|
||||
"output_property": output_property,
|
||||
"required_fields": required_fields,
|
||||
"default_value": default_value,
|
||||
}
|
||||
|
||||
if timeout_seconds:
|
||||
request_metadata["timeout_seconds"] = timeout_seconds
|
||||
|
||||
# Request external input - workflow pauses here
|
||||
await ctx.request_info(
|
||||
ExternalInputRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
message=str(evaluated_message),
|
||||
request_type=request_type,
|
||||
metadata=request_metadata,
|
||||
),
|
||||
ExternalInputResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: ExternalInputRequest,
|
||||
response: ExternalInputResponse,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the external input response."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
output_property = original_request.metadata.get("output_property", "Local.externalInput")
|
||||
|
||||
# Store the response value or user_input
|
||||
result = response.value if response.value is not None else response.user_input
|
||||
if output_property:
|
||||
state.set(output_property, result)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
# Mapping of external input action kinds to executor classes
|
||||
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"Question": QuestionExecutor,
|
||||
"RequestExternalInput": RequestExternalInputExecutor,
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Executor for the ``HttpRequestAction`` declarative action.
|
||||
|
||||
Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
|
||||
configured :class:`HttpRequestHandler`, parses the response body, and assigns
|
||||
the parsed body and response headers to the declared state paths.
|
||||
|
||||
Security note: response bodies can echo secrets and may be very large. Diagnostic
|
||||
messages produced for non-2xx responses truncate the body to 256 characters and
|
||||
collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeWorkflowState,
|
||||
)
|
||||
from ._errors import DeclarativeActionError
|
||||
from ._http_handler import HttpRequestHandler, HttpRequestInfo, HttpRequestResult
|
||||
|
||||
__all__ = [
|
||||
"HTTP_ACTION_EXECUTORS",
|
||||
"HttpRequestActionExecutor",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BODY_DIAGNOSTIC_LENGTH = 256
|
||||
_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
|
||||
|
||||
|
||||
# Body discriminator aliases. Long forms match the .NET object-model type
|
||||
# names so YAML produced by .NET round-trips. Short forms are the .NET YAML
|
||||
# convention used in test fixtures.
|
||||
_BODY_KIND_JSON = {"json", "JsonRequestContent"}
|
||||
_BODY_KIND_RAW = {"raw", "RawRequestContent"}
|
||||
_BODY_KIND_NONE = {"none", "NoRequestContent"}
|
||||
|
||||
|
||||
def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
||||
"""Extract a state path from ``response``/``responseHeaders`` field.
|
||||
|
||||
Supports two YAML shapes (matches .NET serialization round-trips):
|
||||
|
||||
- ``response: Local.MyVar`` (plain string).
|
||||
- ``response: { path: Local.MyVar }`` (object form).
|
||||
"""
|
||||
value = action_def.get(key)
|
||||
if isinstance(value, str):
|
||||
return value or None
|
||||
if isinstance(value, Mapping):
|
||||
path = value.get("path") # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return path if isinstance(path, str) and path else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_body_for_diagnostics(body: str | None) -> str:
|
||||
"""Truncate and sanitise a response body for inclusion in error messages.
|
||||
|
||||
Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
|
||||
|
||||
- Empty/None -> empty string.
|
||||
- Replaces CR/LF/TAB with spaces.
|
||||
- Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
|
||||
head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
|
||||
sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
||||
return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
|
||||
|
||||
|
||||
def _parse_response_body(body: str | None) -> Any:
|
||||
"""Parse an HTTP response body the same way the .NET executor does.
|
||||
|
||||
JSON-first: if the body parses as JSON, the parsed value is returned. Other
|
||||
bodies are returned as the raw string. Empty/None bodies return ``None``.
|
||||
"""
|
||||
if body is None or body == "":
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return body
|
||||
|
||||
|
||||
def _format_query_value(value: Any) -> str | None:
|
||||
"""Format a query-parameter value for URL inclusion.
|
||||
|
||||
Mirrors .NET ``FormatQueryValue``: ``None`` is dropped, ``bool`` becomes
|
||||
lower-case ``"true"``/``"false"``, numerics use invariant ``str()``, and
|
||||
other values fall through to ``str()``.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
|
||||
"""Return the configured conversation messages path, if any.
|
||||
|
||||
Returns ``System.conversations.{evaluated_id}.messages`` when a
|
||||
``conversation_id_expr`` is configured and evaluates to a non-empty value.
|
||||
Returns ``None`` when no conversation id expression is configured or when
|
||||
the expression evaluates to ``None`` or an empty string (matches .NET
|
||||
``GetConversationId`` behaviour where empty becomes ``null`` and the
|
||||
response is not appended).
|
||||
"""
|
||||
if not conversation_id_expr:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(conversation_id_expr)
|
||||
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
|
||||
return None
|
||||
return f"System.conversations.{evaluated}.messages"
|
||||
|
||||
|
||||
class HttpRequestActionExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ``HttpRequestAction`` declarative action.
|
||||
|
||||
Dispatches through the supplied :class:`HttpRequestHandler` and:
|
||||
|
||||
- Parses the response body (JSON-first, raw string fall-back).
|
||||
- Assigns the parsed body to ``response`` path (if configured).
|
||||
- Folds multi-value response headers (comma-joined) and assigns them to
|
||||
``responseHeaders`` path (if configured).
|
||||
- On 2xx with non-empty body and a configured ``conversationId``, appends
|
||||
an Assistant :class:`agent_framework.Message` to
|
||||
``System.conversations.{id}.messages``.
|
||||
- On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
|
||||
:class:`DeclarativeActionError` with a status-coded message containing a
|
||||
truncated/sanitised body preview.
|
||||
|
||||
Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
|
||||
``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
|
||||
is intentionally NOT caught so that workflow cancellation propagates.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
*,
|
||||
id: str | None = None,
|
||||
http_request_handler: HttpRequestHandler,
|
||||
) -> None:
|
||||
"""Create an HTTP request action executor.
|
||||
|
||||
Args:
|
||||
action_def: Parsed ``HttpRequestAction`` YAML dict.
|
||||
id: Optional executor id (defaults to action id or generated).
|
||||
http_request_handler: Handler used to dispatch HTTP requests.
|
||||
Required: the builder enforces presence at workflow-build time.
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._http_request_handler = http_request_handler
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Execute the HTTP request action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
method = self._get_method(state)
|
||||
url = self._get_url(state)
|
||||
headers = self._get_headers(state)
|
||||
query_parameters = self._get_query_parameters(state)
|
||||
body, body_content_type = self._get_body(state)
|
||||
timeout_ms = self._get_timeout_ms(state)
|
||||
conversation_id_expr = self._action_def.get("conversationId")
|
||||
connection_name = self._get_connection_name(state)
|
||||
|
||||
info = HttpRequestInfo(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers or {},
|
||||
query_parameters=query_parameters or {},
|
||||
body=body,
|
||||
body_content_type=body_content_type,
|
||||
timeout_ms=timeout_ms,
|
||||
connection_name=connection_name,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._http_request_handler.send(info)
|
||||
except (httpx.TimeoutException, TimeoutError) as exc:
|
||||
raise DeclarativeActionError(f"HTTP request to '{url}' timed out.") from exc
|
||||
except DeclarativeActionError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
|
||||
except Exception as exc:
|
||||
# Custom HttpRequestHandler implementations may raise arbitrary
|
||||
# exception types. Wrap them in DeclarativeActionError so workflow
|
||||
# error handling stays uniform regardless of transport. Note that
|
||||
# ``asyncio.CancelledError`` is a ``BaseException`` (not
|
||||
# ``Exception``) and so still propagates unmodified, preserving
|
||||
# workflow-cancellation semantics.
|
||||
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
|
||||
|
||||
if result.is_success_status_code:
|
||||
self._assign_response(state, result)
|
||||
self._assign_response_headers(state, result)
|
||||
self._append_response_to_conversation(state, conversation_id_expr, result.body)
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
# Non-success path: still publish headers diagnostically, then raise.
|
||||
self._assign_response_headers(state, result)
|
||||
body_preview = _format_body_for_diagnostics(result.body)
|
||||
if body_preview:
|
||||
message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
|
||||
else:
|
||||
message = f"HTTP request to '{url}' failed with status code {result.status_code}."
|
||||
raise DeclarativeActionError(message)
|
||||
|
||||
# ----- Field resolution ----------------------------------------------------
|
||||
|
||||
def _get_method(self, state: DeclarativeWorkflowState) -> str:
|
||||
method = self._action_def.get("method")
|
||||
evaluated = state.eval_if_expression(method) if method is not None else None
|
||||
if not evaluated:
|
||||
return "GET"
|
||||
return str(evaluated).upper()
|
||||
|
||||
def _get_url(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("url")
|
||||
if raw is None:
|
||||
raise ValueError("HttpRequestAction requires a 'url' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("HttpRequestAction 'url' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
|
||||
raw_headers = self._action_def.get("headers")
|
||||
if not isinstance(raw_headers, Mapping) or not raw_headers:
|
||||
return None
|
||||
result: dict[str, str] = {}
|
||||
for key, value in raw_headers.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
if evaluated is None:
|
||||
continue
|
||||
text = str(evaluated)
|
||||
if not text:
|
||||
continue
|
||||
result[key] = text
|
||||
return result or None
|
||||
|
||||
def _get_query_parameters(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
|
||||
raw_params = self._action_def.get("queryParameters")
|
||||
if not isinstance(raw_params, Mapping) or not raw_params:
|
||||
return None
|
||||
result: dict[str, str] = {}
|
||||
for key, value in raw_params.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key or value is None:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
formatted = _format_query_value(evaluated)
|
||||
if formatted is not None:
|
||||
result[key] = formatted
|
||||
return result or None
|
||||
|
||||
def _get_body(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None]:
|
||||
raw_body = self._action_def.get("body")
|
||||
if raw_body is None:
|
||||
return None, None
|
||||
if not isinstance(raw_body, Mapping):
|
||||
raise ValueError(
|
||||
"HttpRequestAction 'body' must be a mapping with a 'kind' field (json, raw) or omitted entirely."
|
||||
)
|
||||
|
||||
kind_value: Any = raw_body.get("kind") or raw_body.get("$kind") # type: ignore[reportUnknownMemberType]
|
||||
if kind_value is None:
|
||||
raise ValueError(
|
||||
"HttpRequestAction 'body' is missing 'kind'. Use 'json', 'raw', or omit 'body' for no request body."
|
||||
)
|
||||
if not isinstance(kind_value, str):
|
||||
raise ValueError(f"HttpRequestAction 'body.kind' must be a string, got {kind_value!r}.")
|
||||
|
||||
if kind_value in _BODY_KIND_NONE:
|
||||
return None, None
|
||||
|
||||
if kind_value in _BODY_KIND_JSON:
|
||||
content_expr: Any = raw_body.get("content") # type: ignore[reportUnknownMemberType]
|
||||
if content_expr is None:
|
||||
return None, None
|
||||
evaluated = state.eval_if_expression(content_expr)
|
||||
try:
|
||||
body_text = json.dumps(evaluated, default=str)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"HttpRequestAction 'body.content' could not be serialised as JSON: {exc}") from exc
|
||||
return body_text, "application/json"
|
||||
|
||||
if kind_value in _BODY_KIND_RAW:
|
||||
content_expr = raw_body.get("content") # type: ignore[reportUnknownMemberType]
|
||||
content_type_expr: Any = raw_body.get("contentType") # type: ignore[reportUnknownMemberType]
|
||||
content: str | None = None
|
||||
if content_expr is not None:
|
||||
evaluated = state.eval_if_expression(content_expr)
|
||||
content = None if evaluated is None else str(evaluated)
|
||||
content_type: str | None = None
|
||||
if content_type_expr is not None:
|
||||
ct_eval = state.eval_if_expression(content_type_expr)
|
||||
ct_text = None if ct_eval is None else str(ct_eval)
|
||||
content_type = ct_text or None
|
||||
# Match .NET RawRequestContent semantics: when a raw body is sent
|
||||
# without an explicit content type, default to text/plain so the
|
||||
# request is interpretable by servers.
|
||||
if content is not None and not content_type:
|
||||
content_type = "text/plain"
|
||||
return content, content_type
|
||||
|
||||
raise ValueError(
|
||||
f"HttpRequestAction 'body.kind' has unsupported value '{kind_value}'. "
|
||||
"Expected one of: json, raw, JsonRequestContent, RawRequestContent, "
|
||||
"NoRequestContent."
|
||||
)
|
||||
|
||||
def _get_timeout_ms(self, state: DeclarativeWorkflowState) -> int | None:
|
||||
raw = self._action_def.get("requestTimeoutInMilliseconds")
|
||||
if raw is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if evaluated is None:
|
||||
return None
|
||||
try:
|
||||
value = int(evaluated)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
"HttpRequestAction: ignoring non-numeric requestTimeoutInMilliseconds=%r",
|
||||
evaluated,
|
||||
)
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
connection = self._action_def.get("connection")
|
||||
if not isinstance(connection, Mapping):
|
||||
return None
|
||||
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
|
||||
if name_expr is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(name_expr)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
# ----- Result handling -----------------------------------------------------
|
||||
|
||||
def _assign_response(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
|
||||
path = _get_path(self._action_def, "response")
|
||||
if path is None:
|
||||
return
|
||||
state.set(path, _parse_response_body(result.body))
|
||||
|
||||
def _assign_response_headers(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
|
||||
path = _get_path(self._action_def, "responseHeaders")
|
||||
if path is None:
|
||||
return
|
||||
if not result.headers:
|
||||
state.set(path, None)
|
||||
return
|
||||
# Fold multi-value headers with commas (standard HTTP folding) only at
|
||||
# assignment time. The raw multi-value dict on HttpRequestResult.headers
|
||||
# is left untouched so callers/tests can inspect duplicates.
|
||||
flattened: dict[str, str] = {}
|
||||
for key, values in result.headers.items():
|
||||
flattened[key] = ",".join(values)
|
||||
state.set(path, flattened)
|
||||
|
||||
def _append_response_to_conversation(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
conversation_id_expr: str | None,
|
||||
body: str,
|
||||
) -> None:
|
||||
if not body:
|
||||
return
|
||||
messages_path = _get_messages_path(state, conversation_id_expr)
|
||||
if messages_path is None:
|
||||
return
|
||||
# Mirrors InvokeAzureAgentExecutor: rely on state.append to lazily
|
||||
# create the conversation entry. Avoids re-parsing the id back out
|
||||
# of the dotted path string.
|
||||
message = Message(role="assistant", contents=[body])
|
||||
state.append(messages_path, message)
|
||||
|
||||
|
||||
HTTP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"HttpRequestAction": HttpRequestActionExecutor,
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Executor for the ``InvokeMcpTool`` declarative action.
|
||||
|
||||
Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
|
||||
the configured :class:`MCPToolHandler`, parses tool outputs, and routes
|
||||
results to the configured ``output.{result, messages, autoSend}`` paths and
|
||||
optional conversation history. Supports a human-in-loop approval flow via
|
||||
``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
|
||||
|
||||
Security notes:
|
||||
|
||||
- Approval requests surface header NAMES only; header values are not echoed,
|
||||
matching the posture of :mod:`._executors_http`.
|
||||
- :class:`MCPToolApprovalRequest` carries the values the resume handler will
|
||||
use; header values are re-evaluated on resume to keep secrets out of
|
||||
checkpoint state.
|
||||
- Tool outputs flow back into agent conversations through ``conversationId``
|
||||
and through Tool-role messages emitted to ``output.messages``. They share
|
||||
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
|
||||
authors must trust the MCP server they invoke.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
Content,
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeWorkflowState,
|
||||
)
|
||||
from ._executors_tools import ToolApprovalResponse
|
||||
from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
|
||||
|
||||
__all__ = [
|
||||
"MCP_ACTION_EXECUTORS",
|
||||
"InvokeMcpToolActionExecutor",
|
||||
"MCPToolApprovalRequest",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / state types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolApprovalRequest:
|
||||
"""Approval request emitted before invoking an MCP tool.
|
||||
|
||||
Attributes:
|
||||
request_id: Identifier matching the framework's pending-request key.
|
||||
tool_name: Evaluated tool name.
|
||||
server_url: Evaluated MCP server URL.
|
||||
server_label: Optional human-readable label.
|
||||
arguments: Evaluated tool arguments.
|
||||
header_names: Outbound header names (values withheld).
|
||||
connection_name: Connection identifier the invocation will use.
|
||||
metadata: Internal routing data pinned at approval-request time
|
||||
(e.g. ``conversation_id``) for use by the resume handler.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
tool_name: str
|
||||
server_url: str
|
||||
server_label: str | None
|
||||
arguments: dict[str, Any]
|
||||
header_names: list[str] = field(default_factory=lambda: [])
|
||||
connection_name: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=lambda: {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _evaluate_conversation_id(state: DeclarativeWorkflowState, conversation_id_expr: Any) -> str | None:
|
||||
"""Return the evaluated ``conversationId`` string, or None when empty/unset."""
|
||||
if not isinstance(conversation_id_expr, str) or not conversation_id_expr:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(conversation_id_expr)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
|
||||
def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
||||
"""Extract a state path from ``output.{key}`` field.
|
||||
|
||||
Supports two YAML shapes:
|
||||
|
||||
- ``output: { result: Local.MyVar }`` — plain string.
|
||||
- ``output: { result: { path: Local.MyVar } }`` — object form.
|
||||
"""
|
||||
output: Any = action_def.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
return None
|
||||
value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
|
||||
if isinstance(value, str):
|
||||
return value or None
|
||||
if isinstance(value, Mapping):
|
||||
path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
|
||||
return path if isinstance(path, str) and path else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_outputs_for_send(parsed_results: list[Any]) -> str:
|
||||
"""Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
|
||||
|
||||
- Empty list → ``""``.
|
||||
- All-string list → newline-joined.
|
||||
- Single element (any type — scalar, dict, list) → JSON-dumped element.
|
||||
This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
|
||||
an MCP tool returns a single scalar JSON value.
|
||||
- Multi-element non-string list → JSON-dump the whole list.
|
||||
"""
|
||||
if not parsed_results:
|
||||
return ""
|
||||
if all(isinstance(item, str) for item in parsed_results):
|
||||
return "\n".join(parsed_results)
|
||||
if len(parsed_results) == 1:
|
||||
return json.dumps(parsed_results[0], ensure_ascii=False)
|
||||
return json.dumps(parsed_results, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ``InvokeMcpTool`` declarative action.
|
||||
|
||||
Dispatches through the supplied :class:`MCPToolHandler` and:
|
||||
|
||||
- Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
|
||||
/ ``headers`` / ``connection.name`` from the action definition.
|
||||
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
|
||||
via ``ctx.request_info()`` and yields. On resume, the response is
|
||||
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
|
||||
no tool call is made.
|
||||
- On success: parses each :class:`agent_framework.Content` output (text →
|
||||
JSON-first / data / uri → URI string) and assigns the parsed list to
|
||||
``output.result``. Builds a single Tool-role :class:`Message`
|
||||
containing all output contents and assigns it to ``output.messages``.
|
||||
When ``output.autoSend`` is true (default), emits the rendered string
|
||||
via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
|
||||
appends an Assistant-role :class:`Message` with the same contents to
|
||||
``System.conversations.{id}.messages``.
|
||||
- On error returned by the handler (``is_error=True``): assigns
|
||||
``"Error: <message>"`` to ``output.result`` and completes normally
|
||||
(parity with .NET ``AssignErrorAsync``).
|
||||
|
||||
.. note::
|
||||
|
||||
``output.messages`` receives a SINGLE Tool-role :class:`Message`
|
||||
(containing the full tool output as ``contents``), unlike
|
||||
:class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
|
||||
writes a list of two messages (assistant call + tool result). This
|
||||
matches the .NET ``InvokeMcpToolExecutor`` output contract.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
*,
|
||||
id: str | None = None,
|
||||
mcp_tool_handler: MCPToolHandler,
|
||||
) -> None:
|
||||
"""Create an MCP tool action executor.
|
||||
|
||||
Args:
|
||||
action_def: Parsed ``InvokeMcpTool`` YAML dict.
|
||||
id: Optional executor id (defaults to action id or generated).
|
||||
mcp_tool_handler: Handler used to dispatch MCP tool calls.
|
||||
Required: the builder enforces presence at workflow-build
|
||||
time.
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
|
||||
# ----- Main handler --------------------------------------------------------
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Execute the MCP tool action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
server_url = self._get_server_url(state)
|
||||
tool_name = self._get_tool_name(state)
|
||||
server_label = self._get_server_label(state)
|
||||
arguments = self._get_arguments(state)
|
||||
headers = self._get_headers(state)
|
||||
connection_name = self._get_connection_name(state)
|
||||
require_approval = self._get_require_approval(state)
|
||||
auto_send = self._get_auto_send(state)
|
||||
conversation_id_expr = self._action_def.get("conversationId")
|
||||
output_messages_path = _get_output_path(self._action_def, "messages")
|
||||
output_result_path = _get_output_path(self._action_def, "result")
|
||||
|
||||
if require_approval:
|
||||
request_id = str(uuid.uuid4())
|
||||
conversation_id = _evaluate_conversation_id(state, conversation_id_expr)
|
||||
request = MCPToolApprovalRequest(
|
||||
request_id=request_id,
|
||||
tool_name=tool_name,
|
||||
server_url=server_url,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
header_names=sorted(headers.keys()),
|
||||
connection_name=connection_name,
|
||||
metadata={"conversation_id": conversation_id},
|
||||
)
|
||||
logger.info(
|
||||
"%s: requesting approval for MCP tool '%s' on '%s'",
|
||||
self.__class__.__name__,
|
||||
tool_name,
|
||||
server_url,
|
||||
)
|
||||
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
||||
return
|
||||
|
||||
# No approval required - invoke directly.
|
||||
invocation = MCPToolInvocation(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
headers=headers,
|
||||
connection_name=connection_name,
|
||||
)
|
||||
result = await self._invoke_with_narrow_catch(invocation)
|
||||
await self._process_result(
|
||||
ctx=ctx,
|
||||
state=state,
|
||||
result=result,
|
||||
auto_send=auto_send,
|
||||
conversation_id=_evaluate_conversation_id(state, conversation_id_expr),
|
||||
output_messages_path=output_messages_path,
|
||||
output_result_path=output_result_path,
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
# ----- Approval response handler ------------------------------------------
|
||||
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self,
|
||||
original_request: MCPToolApprovalRequest,
|
||||
response: ToolApprovalResponse,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Resume the invocation using the values pinned on ``original_request``."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
tool_name = original_request.tool_name
|
||||
metadata: dict[str, Any] = getattr(original_request, "metadata", None) or {}
|
||||
raw_conversation_id = metadata.get("conversation_id")
|
||||
conversation_id = raw_conversation_id if isinstance(raw_conversation_id, str) and raw_conversation_id else None
|
||||
|
||||
auto_send = self._get_auto_send(state)
|
||||
output_messages_path = _get_output_path(self._action_def, "messages")
|
||||
output_result_path = _get_output_path(self._action_def, "result")
|
||||
|
||||
if not response.approved:
|
||||
logger.info(
|
||||
"%s: MCP tool '%s' rejected: %s",
|
||||
self.__class__.__name__,
|
||||
tool_name,
|
||||
response.reason,
|
||||
)
|
||||
self._assign_error(state, output_result_path, "MCP tool invocation was not approved by user.")
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
invocation = MCPToolInvocation(
|
||||
server_url=original_request.server_url,
|
||||
tool_name=tool_name,
|
||||
server_label=original_request.server_label,
|
||||
arguments=original_request.arguments,
|
||||
headers=self._evaluate_headers(state, self._action_def.get("headers")),
|
||||
connection_name=getattr(original_request, "connection_name", None),
|
||||
)
|
||||
result = await self._invoke_with_narrow_catch(invocation)
|
||||
await self._process_result(
|
||||
ctx=ctx,
|
||||
state=state,
|
||||
result=result,
|
||||
auto_send=auto_send,
|
||||
conversation_id=conversation_id,
|
||||
output_messages_path=output_messages_path,
|
||||
output_result_path=output_result_path,
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
# ----- Field resolution ----------------------------------------------------
|
||||
|
||||
def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("serverUrl")
|
||||
if raw is None:
|
||||
raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("toolName")
|
||||
if raw is None:
|
||||
raise ValueError("InvokeMcpTool requires a 'toolName' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
raw = self._action_def.get("serverLabel")
|
||||
if raw is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
|
||||
"""Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
|
||||
raw = self._action_def.get("arguments")
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, Mapping) or not raw:
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
result[key] = state.eval_if_expression(value)
|
||||
return result
|
||||
|
||||
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
|
||||
return self._evaluate_headers(state, self._action_def.get("headers"))
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
|
||||
"""Evaluate the ``headers`` map. Empty string values are skipped."""
|
||||
if not isinstance(headers_def, Mapping) or not headers_def:
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
if evaluated is None:
|
||||
continue
|
||||
text = str(evaluated)
|
||||
if not text:
|
||||
continue
|
||||
result[key] = text
|
||||
return result
|
||||
|
||||
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
connection = self._action_def.get("connection")
|
||||
if not isinstance(connection, Mapping):
|
||||
return None
|
||||
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
|
||||
if name_expr is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(name_expr)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
|
||||
raw = self._action_def.get("requireApproval")
|
||||
if raw is None:
|
||||
return False
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if isinstance(evaluated, bool):
|
||||
return evaluated
|
||||
if isinstance(evaluated, str):
|
||||
return evaluated.strip().lower() in {"true", "1", "yes"}
|
||||
return bool(evaluated)
|
||||
|
||||
def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
|
||||
output: Any = self._action_def.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
return True
|
||||
raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
|
||||
if raw is None:
|
||||
return True
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if isinstance(evaluated, bool):
|
||||
return evaluated
|
||||
if isinstance(evaluated, str):
|
||||
return evaluated.strip().lower() in {"true", "1", "yes"}
|
||||
return bool(evaluated)
|
||||
|
||||
# ----- Invocation + error handling ----------------------------------------
|
||||
|
||||
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke the handler with a narrow exception catch.
|
||||
|
||||
Only known transport / tool exceptions are normalised to an error
|
||||
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
|
||||
propagate so they fail loudly.
|
||||
|
||||
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
|
||||
so it is not caught here and propagates unchanged for workflow
|
||||
cancellation.
|
||||
"""
|
||||
try:
|
||||
return await self._mcp_tool_handler.invoke_tool(invocation)
|
||||
except ToolExecutionException as exc:
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ImportError: # pragma: no cover - mcp is a hard dep
|
||||
raise
|
||||
if isinstance(exc, McpError):
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
raise
|
||||
|
||||
# ----- Result handling -----------------------------------------------------
|
||||
|
||||
async def _process_result(
|
||||
self,
|
||||
*,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
state: DeclarativeWorkflowState,
|
||||
result: MCPToolResult,
|
||||
auto_send: bool,
|
||||
conversation_id: str | None,
|
||||
output_messages_path: str | None,
|
||||
output_result_path: str | None,
|
||||
) -> None:
|
||||
"""Apply ``result`` to workflow state per the configured output paths."""
|
||||
if result.is_error:
|
||||
# Error path mirrors .NET ``AssignErrorAsync`` — only the result
|
||||
# path is touched; messages / autoSend / conversation are not.
|
||||
self._assign_error(
|
||||
state,
|
||||
output_result_path,
|
||||
result.error_message or "MCP tool invocation failed.",
|
||||
)
|
||||
return
|
||||
|
||||
parsed_results = _parse_outputs(result.outputs)
|
||||
if output_result_path is not None and parsed_results:
|
||||
state.set(output_result_path, parsed_results)
|
||||
|
||||
# Single Tool-role message (matches .NET line 178 contract). Differs
|
||||
# from InvokeFunctionTool's two-message [assistant call, tool result]
|
||||
# convention.
|
||||
tool_message = Message(role="tool", contents=list(result.outputs))
|
||||
if output_messages_path is not None:
|
||||
state.set(output_messages_path, tool_message)
|
||||
|
||||
if auto_send and parsed_results:
|
||||
await ctx.yield_output(_format_outputs_for_send(parsed_results))
|
||||
|
||||
if conversation_id:
|
||||
messages_path = f"System.conversations.{conversation_id}.messages"
|
||||
assistant_message = Message(role="assistant", contents=list(result.outputs))
|
||||
state.append(messages_path, assistant_message)
|
||||
|
||||
@staticmethod
|
||||
def _assign_error(
|
||||
state: DeclarativeWorkflowState,
|
||||
output_result_path: str | None,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
"""Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
|
||||
if output_result_path is None:
|
||||
return
|
||||
state.set(output_result_path, f"Error: {error_message}")
|
||||
|
||||
|
||||
def _parse_outputs(outputs: list[Content]) -> list[Any]:
|
||||
"""Parse :class:`Content` outputs into Python values for ``output.result``.
|
||||
|
||||
Mirrors .NET ``AssignResultAsync``:
|
||||
|
||||
- ``TextContent`` → JSON-parse text; on failure use the raw text.
|
||||
- ``DataContent`` / ``UriContent`` → ``content.uri``.
|
||||
- Other content kinds → ``str(content)``.
|
||||
"""
|
||||
parsed: list[Any] = []
|
||||
for content in outputs:
|
||||
kind = getattr(content, "type", None)
|
||||
if kind == "text":
|
||||
text_value = getattr(content, "text", None)
|
||||
text_str = "" if text_value is None else str(text_value)
|
||||
try:
|
||||
parsed.append(json.loads(text_str))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed.append(text_str)
|
||||
continue
|
||||
if kind in ("data", "uri"):
|
||||
uri_value = getattr(content, "uri", None)
|
||||
parsed.append("" if uri_value is None else str(uri_value))
|
||||
continue
|
||||
parsed.append(str(content))
|
||||
return parsed
|
||||
|
||||
|
||||
MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"InvokeMcpTool": InvokeMcpToolActionExecutor,
|
||||
}
|
||||
+660
@@ -0,0 +1,660 @@
|
||||
# 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 collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from inspect import isawaitable
|
||||
from typing import Any, cast
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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=cast(Callable[..., list[Message]], 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: dict[str, str | bool] = self._action_def.get("output", {})
|
||||
|
||||
if not isinstance(output_config, Mapping):
|
||||
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(): # type: ignore[reportUnknownVariableType]
|
||||
arguments[key] = state.eval_if_expression(value)
|
||||
|
||||
# Check if approval is required
|
||||
require_approval = self._action_def.get("requireApproval", False)
|
||||
|
||||
if require_approval:
|
||||
# Emit approval request - the request payload is the source of
|
||||
# truth for resumed invocation; no side-channel state is written.
|
||||
request_id = str(uuid.uuid4())
|
||||
request = ToolApprovalRequest(
|
||||
request_id=request_id,
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'")
|
||||
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
||||
# 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.
|
||||
|
||||
Resumes after the workflow yielded for approval. The invocation
|
||||
``function_name`` and ``arguments`` are sourced from
|
||||
``original_request`` (the payload the reviewer approved); output
|
||||
configuration is re-derived from the executor's action definition.
|
||||
"""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
function_name = original_request.function_name
|
||||
arguments = original_request.arguments
|
||||
messages_var, result_var, auto_send = self._get_output_config()
|
||||
|
||||
# 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",
|
||||
contents=[
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""WorkflowFactory creates executable Workflow objects from YAML definitions.
|
||||
|
||||
This module provides the main entry point for declarative workflow support,
|
||||
parsing YAML workflow definitions and creating Workflow objects that can be
|
||||
executed using the core workflow runtime.
|
||||
|
||||
Each YAML action becomes a real Executor node in the workflow graph,
|
||||
enabling checkpointing, visualization, and pause/resume capabilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import yaml
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
CheckpointStorage,
|
||||
SupportsAgentRun,
|
||||
Workflow,
|
||||
)
|
||||
|
||||
from .._loader import AgentFactory
|
||||
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._http_handler import HttpRequestHandler
|
||||
from ._mcp_handler import MCPToolHandler
|
||||
|
||||
logger = logging.getLogger("agent_framework.declarative")
|
||||
|
||||
|
||||
__all__ = ["WorkflowFactory"]
|
||||
|
||||
|
||||
class WorkflowFactory:
|
||||
"""Factory for creating executable Workflow objects from YAML definitions.
|
||||
|
||||
WorkflowFactory parses declarative workflow YAML files and creates
|
||||
Workflow objects that can be executed using the core workflow runtime.
|
||||
Each YAML action becomes a real Executor node in the workflow graph,
|
||||
enabling checkpointing at action boundaries, visualization, and pause/resume.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Basic usage: create workflow from YAML file
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
|
||||
|
||||
async for event in workflow.run({"query": "Hello"}, stream=True):
|
||||
print(event)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
from agent_framework import FileCheckpointStorage
|
||||
|
||||
# With checkpointing for pause/resume support
|
||||
storage = FileCheckpointStorage(path="./checkpoints")
|
||||
factory = WorkflowFactory(checkpoint_storage=storage)
|
||||
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Pre-register agents for InvokeAzureAgent actions
|
||||
client = OpenAIChatClient()
|
||||
agent = client.as_agent(name="MyAgent", instructions="You are helpful.")
|
||||
|
||||
factory = WorkflowFactory(agents={"MyAgent": agent})
|
||||
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
|
||||
"""
|
||||
|
||||
_agents: dict[str, SupportsAgentRun | AgentExecutor]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
agent_factory: AgentFactory | None = None,
|
||||
agents: Mapping[str, SupportsAgentRun | AgentExecutor] | None = None,
|
||||
bindings: Mapping[str, Any] | None = None,
|
||||
env_file: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
configuration: Mapping[str, str] | None = None,
|
||||
restrict_env_to_configuration: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the workflow factory.
|
||||
|
||||
Args:
|
||||
agent_factory: Optional AgentFactory for creating agents from inline YAML definitions.
|
||||
agents: Optional pre-created agents by name. These are looked up when processing
|
||||
InvokeAzureAgent actions in the workflow YAML.
|
||||
bindings: Optional function bindings for tool calls within workflow actions.
|
||||
env_file: Optional path to .env file for environment variables used in agent creation.
|
||||
checkpoint_storage: Optional checkpoint storage enabling pause/resume functionality.
|
||||
max_iterations: Optional maximum runner supersteps. Overrides the YAML ``maxTurns``
|
||||
field and the core default (100). Workflows with ``GotoAction`` loops (e.g.
|
||||
DeepResearch) typically need a higher value.
|
||||
http_request_handler: Optional handler used to dispatch HTTP requests for
|
||||
``HttpRequestAction``. Required if the workflow contains any
|
||||
``HttpRequestAction``; build will fail with :class:`DeclarativeWorkflowError`
|
||||
otherwise. Use :class:`agent_framework.declarative.DefaultHttpRequestHandler`
|
||||
for a no-policy ``httpx``-based default, or supply your own implementation
|
||||
to enforce SSRF guards, allowlisting, or auth resolution.
|
||||
mcp_tool_handler: Optional handler used to dispatch MCP tool calls for
|
||||
``InvokeMcpTool``. Required if the workflow contains any
|
||||
``InvokeMcpTool``; build will fail with :class:`DeclarativeWorkflowError`
|
||||
otherwise. Use :class:`agent_framework.declarative.DefaultMCPToolHandler`
|
||||
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
|
||||
or supply your own implementation to enforce SSRF guards, allowlisting,
|
||||
or auth/connection resolution.
|
||||
configuration: Optional mapping that populates the PowerFx ``Env``
|
||||
symbol referenced from workflow YAML expressions (e.g.
|
||||
``=Env.MY_KEY``). Keys supplied here are always exposed
|
||||
under ``Env.<key>``; the process ``os.environ`` is consulted
|
||||
only when ``restrict_env_to_configuration`` is ``False``.
|
||||
When neither source produces a value the ``Env`` symbol is
|
||||
omitted so ``=Env.X`` evaluates to the literal expression
|
||||
string.
|
||||
restrict_env_to_configuration: When ``True`` (default), the
|
||||
``Env`` PowerFx symbol is populated exclusively from
|
||||
``configuration``; ``os.environ`` is never consulted. Set to
|
||||
``False`` to additionally fall back to ``os.environ`` for
|
||||
names absent from ``configuration`` that the workflow YAML
|
||||
explicitly references. The fallback is constrained to names
|
||||
discovered in PowerFx expressions inside the workflow
|
||||
definition so unrelated environment variables never enter
|
||||
the PowerFx scope.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Minimal initialization
|
||||
factory = WorkflowFactory()
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# With pre-registered agents
|
||||
client = OpenAIChatClient()
|
||||
agents = {
|
||||
"WriterAgent": client.as_agent(name="Writer", instructions="Write content."),
|
||||
"ReviewerAgent": client.as_agent(name="Reviewer", instructions="Review content."),
|
||||
}
|
||||
factory = WorkflowFactory(agents=agents)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import FileCheckpointStorage
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# With checkpoint storage for pause/resume
|
||||
factory = WorkflowFactory(
|
||||
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
|
||||
env_file=".env",
|
||||
)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Inject named values for =Env.* references in the workflow YAML
|
||||
factory = WorkflowFactory(
|
||||
configuration={
|
||||
"MY_SERVER_URL": "https://example.com",
|
||||
"MY_TOOL_NAME": "search",
|
||||
},
|
||||
)
|
||||
"""
|
||||
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
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
|
||||
self._restrict_env_to_configuration = restrict_env_to_configuration
|
||||
|
||||
def create_workflow_from_yaml_path(
|
||||
self,
|
||||
yaml_path: str | Path,
|
||||
) -> Workflow:
|
||||
"""Create a Workflow from a YAML file path.
|
||||
|
||||
Args:
|
||||
yaml_path: Path to the YAML workflow definition file.
|
||||
|
||||
Returns:
|
||||
An executable Workflow object with action nodes for each YAML action.
|
||||
|
||||
Raises:
|
||||
DeclarativeWorkflowError: If the YAML is invalid or cannot be parsed.
|
||||
FileNotFoundError: If the YAML file doesn't exist.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
|
||||
|
||||
# Execute the workflow
|
||||
async for event in workflow.run({"input": "Hello"}, stream=True):
|
||||
print(event)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pathlib import Path
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Using Path object
|
||||
workflow_path = Path(__file__).parent / "workflows" / "customer_support.yaml"
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml_path(workflow_path)
|
||||
"""
|
||||
if not isinstance(yaml_path, Path):
|
||||
yaml_path = Path(yaml_path)
|
||||
|
||||
if not yaml_path.exists():
|
||||
raise FileNotFoundError(f"Workflow YAML file not found: {yaml_path}")
|
||||
|
||||
with open(yaml_path) as f:
|
||||
yaml_content = f.read()
|
||||
|
||||
return self.create_workflow_from_yaml(yaml_content, base_path=yaml_path.parent)
|
||||
|
||||
def create_workflow_from_yaml(
|
||||
self,
|
||||
yaml_content: str,
|
||||
base_path: Path | None = None,
|
||||
) -> Workflow:
|
||||
"""Create a Workflow from a YAML string.
|
||||
|
||||
Args:
|
||||
yaml_content: The YAML workflow definition as a string.
|
||||
base_path: Optional base path for resolving relative file references
|
||||
in agent definitions.
|
||||
|
||||
Returns:
|
||||
An executable Workflow object with action nodes for each YAML action.
|
||||
|
||||
Raises:
|
||||
DeclarativeWorkflowError: If the YAML is invalid or cannot be parsed.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
yaml_content = '''
|
||||
kind: Workflow
|
||||
trigger:
|
||||
kind: OnConversationStart
|
||||
id: greeting_workflow
|
||||
actions:
|
||||
- kind: SetVariable
|
||||
id: set_greeting
|
||||
variable: Local.Greeting
|
||||
value: "Hello, World!"
|
||||
- kind: SendActivity
|
||||
id: send_greeting
|
||||
activity: =Local.Greeting
|
||||
'''
|
||||
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml(yaml_content)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pathlib import Path
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# With base_path for resolving relative agent file references
|
||||
yaml_content = '''
|
||||
kind: Workflow
|
||||
agents:
|
||||
MyAgent:
|
||||
file: ./agents/my_agent.yaml
|
||||
trigger:
|
||||
actions:
|
||||
- kind: InvokeAzureAgent
|
||||
agent:
|
||||
name: MyAgent
|
||||
'''
|
||||
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml(
|
||||
yaml_content,
|
||||
base_path=Path("./workflows"),
|
||||
)
|
||||
"""
|
||||
try:
|
||||
workflow_def = yaml.safe_load(yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
raise DeclarativeWorkflowError(f"Invalid YAML: {e}") from e
|
||||
|
||||
return self.create_workflow_from_definition(workflow_def, base_path=base_path)
|
||||
|
||||
def create_workflow_from_definition(
|
||||
self,
|
||||
workflow_def: dict[str, Any],
|
||||
base_path: Path | None = None,
|
||||
) -> Workflow:
|
||||
"""Create a Workflow from a parsed workflow definition dictionary.
|
||||
|
||||
This is the lowest-level creation method, useful when you already have
|
||||
a parsed dictionary (e.g., from programmatic construction or custom parsing).
|
||||
|
||||
Args:
|
||||
workflow_def: The parsed workflow definition dictionary containing
|
||||
'kind', 'trigger', 'actions', and optionally 'agents' keys.
|
||||
base_path: Optional base path for resolving relative file references
|
||||
in agent definitions.
|
||||
|
||||
Returns:
|
||||
An executable Workflow object with action nodes for each YAML action.
|
||||
|
||||
Raises:
|
||||
DeclarativeWorkflowError: If the definition is invalid or missing required fields.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Programmatically construct a workflow definition
|
||||
workflow_def = {
|
||||
"kind": "Workflow",
|
||||
"name": "my_workflow",
|
||||
"trigger": {
|
||||
"kind": "OnConversationStart",
|
||||
"id": "main_trigger",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "SetVariable",
|
||||
"id": "init",
|
||||
"variable": "Local.Counter",
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"kind": "SendActivity",
|
||||
"id": "output",
|
||||
"activity": "Counter initialized",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_definition(workflow_def)
|
||||
"""
|
||||
# Validate the workflow definition
|
||||
self._validate_workflow_def(workflow_def)
|
||||
|
||||
# Extract workflow metadata
|
||||
# Support both "name" field and trigger.id for workflow name
|
||||
name: str = workflow_def.get("name", "")
|
||||
if not name:
|
||||
trigger: dict[str, Any] = workflow_def.get("trigger", {})
|
||||
trigger_id = trigger.get("id", "declarative_workflow")
|
||||
name = str(trigger_id) if trigger_id else "declarative_workflow"
|
||||
description = workflow_def.get("description")
|
||||
|
||||
# Create agents from definitions
|
||||
agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(self._agents)
|
||||
agent_defs = workflow_def.get("agents", {})
|
||||
|
||||
for agent_name, agent_def in agent_defs.items():
|
||||
if agent_name in agents:
|
||||
# Already have this agent
|
||||
continue
|
||||
|
||||
# Create agent using AgentFactory
|
||||
try:
|
||||
agent = self._create_agent_from_def(agent_def, base_path)
|
||||
agents[agent_name] = agent
|
||||
logger.debug(f"Created agent '{agent_name}' from definition")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create agent '{agent_name}': {e}")
|
||||
raise DeclarativeWorkflowError(f"Failed to create agent '{agent_name}': {e}") from e
|
||||
|
||||
return self._create_workflow(workflow_def, name, description, agents)
|
||||
|
||||
def _create_workflow(
|
||||
self,
|
||||
workflow_def: dict[str, Any],
|
||||
name: str,
|
||||
description: str | None,
|
||||
agents: dict[str, SupportsAgentRun | AgentExecutor],
|
||||
) -> Workflow:
|
||||
"""Create workflow from definition.
|
||||
|
||||
Each YAML action becomes a real Executor node in the workflow graph.
|
||||
This enables checkpointing at action boundaries.
|
||||
|
||||
Args:
|
||||
workflow_def: The workflow definition
|
||||
name: Workflow name
|
||||
description: Workflow description
|
||||
agents: Registry of agent instances
|
||||
|
||||
Returns:
|
||||
Workflow with individual action executors as nodes
|
||||
"""
|
||||
# Normalize workflow definition to have actions at top level
|
||||
normalized_def = self._normalize_workflow_def(workflow_def)
|
||||
normalized_def["name"] = name
|
||||
if description:
|
||||
normalized_def["description"] = description
|
||||
|
||||
# Build the DeclarativeEnvConfig from the factory's configuration and the
|
||||
# set of Env references actually used in the workflow PowerFx expressions.
|
||||
# The referenced-name allowlist constrains ``os.environ`` fallback (when
|
||||
# enabled) so unrelated variables never enter the PowerFx scope.
|
||||
env_config = DeclarativeEnvConfig(
|
||||
values=dict(self._configuration),
|
||||
restrict_to_configuration=self._restrict_env_to_configuration,
|
||||
referenced_names=frozenset(discover_env_references(normalized_def)),
|
||||
)
|
||||
|
||||
# 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,
|
||||
http_request_handler=self._http_request_handler,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
env_config=env_config,
|
||||
)
|
||||
workflow = graph_builder.build()
|
||||
except ValueError as e:
|
||||
raise DeclarativeWorkflowError(f"Failed to build graph-based workflow: {e}") from e
|
||||
|
||||
# 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
|
||||
if "inputs" in workflow_def:
|
||||
workflow.input_schema = self._convert_inputs_to_json_schema(workflow_def["inputs"]) # type: ignore[attr-defined]
|
||||
|
||||
logger.debug(
|
||||
"Created graph-based workflow '%s' with %d executors",
|
||||
name,
|
||||
len(graph_builder._executors), # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
return workflow
|
||||
|
||||
def _normalize_workflow_def(self, workflow_def: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize workflow definition to have actions at top level.
|
||||
|
||||
Args:
|
||||
workflow_def: The workflow definition
|
||||
|
||||
Returns:
|
||||
Normalized definition with actions at top level
|
||||
"""
|
||||
actions = self._get_actions_from_def(workflow_def)
|
||||
return {
|
||||
**workflow_def,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
def _validate_workflow_def(self, workflow_def: dict[str, Any]) -> None:
|
||||
"""Validate a workflow definition.
|
||||
|
||||
Args:
|
||||
workflow_def: The workflow definition to validate
|
||||
|
||||
Raises:
|
||||
DeclarativeWorkflowError: If the definition is invalid
|
||||
"""
|
||||
if not isinstance(workflow_def, dict):
|
||||
raise DeclarativeWorkflowError("Workflow definition must be a dictionary")
|
||||
|
||||
# Handle both formats:
|
||||
# 1. Direct actions list: {"actions": [...]}
|
||||
# 2. Trigger-based: {"kind": "Workflow", "trigger": {"actions": [...]}}
|
||||
actions = self._get_actions_from_def(workflow_def)
|
||||
|
||||
if not isinstance(actions, list):
|
||||
raise DeclarativeWorkflowError("Workflow 'actions' must be a list")
|
||||
|
||||
# Validate each action has a kind
|
||||
for i, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
raise DeclarativeWorkflowError(f"Action at index {i} must be a dictionary")
|
||||
if "kind" not in action:
|
||||
raise DeclarativeWorkflowError(f"Action at index {i} missing 'kind' field")
|
||||
|
||||
def _get_actions_from_def(self, workflow_def: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Extract actions from a workflow definition.
|
||||
|
||||
Handles both direct actions format and trigger-based format.
|
||||
|
||||
Args:
|
||||
workflow_def: The workflow definition
|
||||
|
||||
Returns:
|
||||
List of action definitions
|
||||
|
||||
Raises:
|
||||
DeclarativeWorkflowError: If no actions can be found
|
||||
"""
|
||||
# Try direct actions first
|
||||
if "actions" in workflow_def:
|
||||
actions: list[dict[str, Any]] = workflow_def["actions"]
|
||||
return actions
|
||||
|
||||
# Try trigger-based format
|
||||
if "trigger" in workflow_def:
|
||||
trigger = workflow_def["trigger"]
|
||||
if isinstance(trigger, dict) and "actions" in trigger:
|
||||
trigger_actions: list[dict[str, Any]] = list(trigger["actions"]) # type: ignore[arg-type]
|
||||
return trigger_actions
|
||||
|
||||
raise DeclarativeWorkflowError("Workflow definition must have 'actions' field or 'trigger.actions' field")
|
||||
|
||||
def _create_agent_from_def(
|
||||
self,
|
||||
agent_def: dict[str, Any],
|
||||
base_path: Path | None = None,
|
||||
) -> Any:
|
||||
"""Create an agent from a definition.
|
||||
|
||||
Args:
|
||||
agent_def: The agent definition dictionary
|
||||
base_path: Optional base path for resolving relative file references
|
||||
|
||||
Returns:
|
||||
An agent instance
|
||||
"""
|
||||
# Check if it's a reference to an external file
|
||||
if "file" in agent_def:
|
||||
file_path = agent_def["file"]
|
||||
if base_path and not Path(file_path).is_absolute():
|
||||
file_path = base_path / file_path
|
||||
return self._agent_factory.create_agent_from_yaml_path(file_path)
|
||||
|
||||
# Check if it's an inline agent definition
|
||||
if "kind" in agent_def:
|
||||
return self._agent_factory.create_agent_from_dict(agent_def)
|
||||
|
||||
# Handle connection-based agent (like Azure AI agents)
|
||||
if "connection" in agent_def:
|
||||
# This would create a hosted agent client
|
||||
# For now, we'll need the user to provide pre-created agents
|
||||
raise DeclarativeWorkflowError(
|
||||
"Connection-based agents must be provided via the 'agents' parameter. "
|
||||
"Create the agent using the appropriate client and pass it to WorkflowFactory."
|
||||
)
|
||||
|
||||
raise DeclarativeWorkflowError(
|
||||
f"Invalid agent definition. Expected 'file', 'kind', or 'connection': {agent_def}"
|
||||
)
|
||||
|
||||
def register_agent(self, name: str, agent: SupportsAgentRun | AgentExecutor) -> WorkflowFactory:
|
||||
"""Register an agent instance with the factory for use in workflows.
|
||||
|
||||
Registered agents are available to InvokeAzureAgent actions by name.
|
||||
This method supports fluent chaining.
|
||||
|
||||
Args:
|
||||
name: The name to register the agent under. Must match the agent name
|
||||
referenced in InvokeAzureAgent actions.
|
||||
agent: The agent instance (typically a Agent or similar).
|
||||
|
||||
Returns:
|
||||
Self for method chaining.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Method chaining to register multiple agents
|
||||
factory = (
|
||||
WorkflowFactory()
|
||||
.register_agent(
|
||||
"Writer",
|
||||
client.as_agent(
|
||||
name="Writer",
|
||||
instructions="Write content.",
|
||||
),
|
||||
)
|
||||
.register_agent(
|
||||
"Reviewer",
|
||||
client.as_agent(
|
||||
name="Reviewer",
|
||||
instructions="Review content.",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
|
||||
"""
|
||||
self._agents[name] = agent
|
||||
return self
|
||||
|
||||
def register_binding(self, name: str, func: Any) -> WorkflowFactory:
|
||||
"""Register a function binding with the factory for use in workflow actions.
|
||||
|
||||
Bindings allow workflow actions to invoke Python functions by name.
|
||||
This method supports fluent chaining.
|
||||
|
||||
Args:
|
||||
name: The name to register the function under.
|
||||
func: The function to bind.
|
||||
|
||||
Returns:
|
||||
Self for method chaining.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
|
||||
def get_weather(location: str) -> str:
|
||||
return f"Weather in {location}: Sunny, 72F"
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> bool:
|
||||
# Send email logic
|
||||
return True
|
||||
|
||||
|
||||
# Register functions for use in workflow
|
||||
factory = (
|
||||
WorkflowFactory()
|
||||
.register_binding("get_weather", get_weather)
|
||||
.register_binding("send_email", send_email)
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
The inputs definition uses a simplified format:
|
||||
inputs:
|
||||
age:
|
||||
type: integer
|
||||
description: The user's age
|
||||
name:
|
||||
type: string
|
||||
|
||||
This is converted to standard JSON Schema format.
|
||||
|
||||
Args:
|
||||
inputs_def: The inputs definition from the workflow YAML
|
||||
|
||||
Returns:
|
||||
A JSON Schema object
|
||||
"""
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for field_name, field_def in inputs_def.items():
|
||||
if isinstance(field_def, dict):
|
||||
# Field has type and possibly other attributes
|
||||
prop: dict[str, Any] = {}
|
||||
field_def_dict: dict[str, Any] = cast(dict[str, Any], field_def)
|
||||
field_type: str = str(field_def_dict.get("type", "string"))
|
||||
|
||||
# Map declarative types to JSON Schema types
|
||||
type_mapping: dict[str, str] = {
|
||||
"string": "string",
|
||||
"str": "string",
|
||||
"integer": "integer",
|
||||
"int": "integer",
|
||||
"number": "number",
|
||||
"float": "number",
|
||||
"boolean": "boolean",
|
||||
"bool": "boolean",
|
||||
"array": "array",
|
||||
"list": "array",
|
||||
"object": "object",
|
||||
"dict": "object",
|
||||
}
|
||||
prop["type"] = type_mapping.get(field_type, field_type)
|
||||
|
||||
# Copy other attributes
|
||||
if "description" in field_def_dict:
|
||||
prop["description"] = field_def_dict["description"]
|
||||
if "default" in field_def_dict:
|
||||
prop["default"] = field_def_dict["default"]
|
||||
if "enum" in field_def_dict:
|
||||
prop["enum"] = field_def_dict["enum"]
|
||||
|
||||
# Check if required (default: true unless explicitly false)
|
||||
if field_def_dict.get("required", True):
|
||||
required.append(field_name)
|
||||
|
||||
properties[field_name] = prop
|
||||
else:
|
||||
# Simple type definition (e.g., "age: integer")
|
||||
type_mapping_simple: dict[str, str] = {
|
||||
"string": "string",
|
||||
"str": "string",
|
||||
"integer": "integer",
|
||||
"int": "integer",
|
||||
"number": "number",
|
||||
"float": "number",
|
||||
"boolean": "boolean",
|
||||
"bool": "boolean",
|
||||
}
|
||||
properties[field_name] = {"type": type_mapping_simple.get(str(field_def), "string")}
|
||||
required.append(field_name)
|
||||
|
||||
schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
@@ -0,0 +1,237 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""HTTP request handler abstraction for declarative workflows.
|
||||
|
||||
Mirrors the .NET ``IHttpRequestHandler`` / ``DefaultHttpRequestHandler`` pair from
|
||||
``Microsoft.Agents.AI.Workflows.Declarative``. Provides:
|
||||
|
||||
- :class:`HttpRequestInfo` — request input data passed from the executor.
|
||||
- :class:`HttpRequestResult` — response data returned to the executor.
|
||||
- :class:`HttpRequestHandler` — :class:`typing.Protocol` callers implement to plug
|
||||
in custom transports (e.g. with allowlisting, mTLS, retries, etc.).
|
||||
- :class:`DefaultHttpRequestHandler` — production-grade default backed by
|
||||
``httpx.AsyncClient``.
|
||||
|
||||
Security note: :class:`DefaultHttpRequestHandler` performs **no** URL filtering
|
||||
or SSRF protection. Production deployments should supply a custom handler that
|
||||
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
|
||||
.NET design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
__all__ = [
|
||||
"DefaultHttpRequestHandler",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpRequestInfo:
|
||||
"""Description of an HTTP request to be dispatched by a :class:`HttpRequestHandler`.
|
||||
|
||||
Mirrors the .NET ``HttpRequestInfo`` record. Field semantics:
|
||||
|
||||
- ``method``: HTTP method (``GET``, ``POST``, etc.). Already upper-cased by the executor.
|
||||
- ``url``: Absolute URL. Already evaluated from the YAML expression.
|
||||
- ``headers``: Single-value header map (case-insensitive keys per HTTP semantics
|
||||
but stored as authored). Empty values are skipped by the executor.
|
||||
- ``query_parameters``: String key/value pairs appended to the URL.
|
||||
- ``body``: Request body bytes/text, or ``None`` for no body.
|
||||
- ``body_content_type``: Content type to send (e.g. ``application/json``).
|
||||
Ignored when ``body`` is ``None``.
|
||||
- ``timeout_ms``: Per-request timeout in milliseconds. ``None`` => use the
|
||||
handler's default.
|
||||
- ``connection_name``: Optional Foundry connection name for handlers that
|
||||
resolve auth/credentials by connection.
|
||||
"""
|
||||
|
||||
method: str
|
||||
url: str
|
||||
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
query_parameters: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
body: str | None = None
|
||||
body_content_type: str | None = None
|
||||
timeout_ms: int | None = None
|
||||
connection_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpRequestResult:
|
||||
"""Response returned by a :class:`HttpRequestHandler`.
|
||||
|
||||
Mirrors the .NET ``HttpRequestResult`` record. ``headers`` preserves
|
||||
multi-value response headers (e.g. multiple ``Set-Cookie`` headers) as a
|
||||
``dict[str, list[str]]``. The executor folds duplicates into a single
|
||||
comma-joined string only at the point it assigns ``responseHeaders`` to
|
||||
workflow state.
|
||||
|
||||
Header keys are normalized to lowercase so that lookups are consistent
|
||||
regardless of the server's transmitted casing (HTTP headers are
|
||||
case-insensitive per RFC 7230 §3.2). Custom :class:`HttpRequestHandler`
|
||||
implementations should follow the same convention.
|
||||
"""
|
||||
|
||||
status_code: int
|
||||
is_success_status_code: bool
|
||||
body: str
|
||||
headers: dict[str, list[str]] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpRequestHandler(Protocol):
|
||||
"""Protocol for HTTP request handlers used by ``HttpRequestAction``.
|
||||
|
||||
Implementations must be safe to call concurrently from multiple workflow
|
||||
runs. Implementations are responsible for any URL allowlisting, SSRF
|
||||
guards, retry policies, auth resolution, and other policies that the
|
||||
workflow author wants applied.
|
||||
"""
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
"""Dispatch ``info`` and return the response result.
|
||||
|
||||
Args:
|
||||
info: Description of the request to send.
|
||||
|
||||
Returns:
|
||||
The response. Implementations should NOT raise on non-2xx status
|
||||
codes; instead, set ``is_success_status_code`` accordingly. They
|
||||
SHOULD raise on transport-level failures (connection refused,
|
||||
DNS errors, timeouts).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
ClientProvider = Callable[[HttpRequestInfo], Awaitable["httpx.AsyncClient | None"]]
|
||||
|
||||
|
||||
class DefaultHttpRequestHandler:
|
||||
"""Default :class:`HttpRequestHandler` backed by :class:`httpx.AsyncClient`.
|
||||
|
||||
Construction modes:
|
||||
|
||||
1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
|
||||
on first ``send()``. Closed by :meth:`aclose`.
|
||||
2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
|
||||
Not closed by :meth:`aclose`.
|
||||
3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
|
||||
lookup (parity with .NET's ``httpClientProvider`` callback). The
|
||||
provider may return ``None`` to fall back to the owned/default client.
|
||||
|
||||
.. warning::
|
||||
|
||||
This handler performs **no** URL filtering or SSRF protection. Wrap or
|
||||
replace it with a custom handler in production.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
client_provider: ClientProvider | None = None,
|
||||
) -> None:
|
||||
self._owned_client: httpx.AsyncClient | None = None
|
||||
self._caller_client = client
|
||||
self._client_provider = client_provider
|
||||
# Guards lazy creation of ``_owned_client`` against concurrent first
|
||||
# ``send()`` calls leaking duplicate clients.
|
||||
self._owned_client_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
"""Dispatch the request and return the parsed result."""
|
||||
if not info.url:
|
||||
raise ValueError("HttpRequestInfo.url must be a non-empty string.")
|
||||
if not info.method:
|
||||
raise ValueError("HttpRequestInfo.method must be a non-empty string.")
|
||||
|
||||
client = await self._resolve_client(info)
|
||||
|
||||
timeout: httpx.Timeout | object
|
||||
if info.timeout_ms is not None and info.timeout_ms > 0:
|
||||
timeout = httpx.Timeout(info.timeout_ms / 1000.0)
|
||||
else:
|
||||
timeout = httpx.USE_CLIENT_DEFAULT
|
||||
|
||||
headers = dict(info.headers)
|
||||
content: bytes | str | None = None
|
||||
if info.body is not None:
|
||||
content = info.body
|
||||
if not _has_header(headers, "content-type"):
|
||||
# Match .NET DefaultHttpRequestHandler: when a body is sent
|
||||
# without an explicit content type, default to ``text/plain``
|
||||
# so the request is interpretable by servers and direct
|
||||
# callers (not just the YAML executor) get sensible defaults.
|
||||
headers["Content-Type"] = info.body_content_type or "text/plain"
|
||||
|
||||
params: Mapping[str, str] | None = info.query_parameters or None
|
||||
|
||||
response = await client.request(
|
||||
method=info.method,
|
||||
url=info.url,
|
||||
params=params,
|
||||
headers=headers or None,
|
||||
content=content,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
|
||||
# Normalize names to lowercase so lookups are consistent and case
|
||||
# variations from the transport do not create duplicate logical keys
|
||||
# (HTTP headers are case-insensitive per RFC 7230 §3.2).
|
||||
result_headers: dict[str, list[str]] = {}
|
||||
for key, value in response.headers.multi_items():
|
||||
result_headers.setdefault(key.lower(), []).append(value)
|
||||
|
||||
body_text = response.text
|
||||
|
||||
return HttpRequestResult(
|
||||
status_code=response.status_code,
|
||||
is_success_status_code=200 <= response.status_code < 300,
|
||||
body=body_text,
|
||||
headers=result_headers,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release the owned client, if any. Caller-owned clients are NOT closed."""
|
||||
if self._owned_client is not None:
|
||||
await self._owned_client.aclose()
|
||||
self._owned_client = None
|
||||
|
||||
async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient:
|
||||
"""Pick a client for this request: provider → caller → lazily-owned."""
|
||||
if self._client_provider is not None:
|
||||
provided = await self._client_provider(info)
|
||||
if provided is not None:
|
||||
return provided
|
||||
if self._caller_client is not None:
|
||||
return self._caller_client
|
||||
if self._owned_client is None:
|
||||
# Double-checked locking under asyncio.Lock so concurrent first
|
||||
# callers don't each create a fresh httpx.AsyncClient and orphan
|
||||
# one of them.
|
||||
async with self._owned_client_lock:
|
||||
if self._owned_client is None:
|
||||
self._owned_client = httpx.AsyncClient()
|
||||
return self._owned_client
|
||||
|
||||
async def __aenter__(self) -> DefaultHttpRequestHandler:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.aclose()
|
||||
|
||||
|
||||
def _has_header(headers: Mapping[str, str], name: str) -> bool:
|
||||
"""Case-insensitive header presence check."""
|
||||
needle = name.lower()
|
||||
return any(key.lower() == needle for key in headers)
|
||||
@@ -0,0 +1,581 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""MCP tool handler abstraction for declarative workflows.
|
||||
|
||||
Mirrors the .NET ``IMcpToolHandler`` / ``DefaultMcpToolHandler`` pair from
|
||||
``Microsoft.Agents.AI.Workflows.Declarative.Mcp``. Provides:
|
||||
|
||||
- :class:`MCPToolInvocation` — request input data passed from the executor.
|
||||
- :class:`MCPToolResult` — response data returned to the executor.
|
||||
- :class:`MCPToolHandler` — :class:`typing.Protocol` callers implement to plug
|
||||
in custom transports (e.g. with allowlisting, Foundry connection resolution,
|
||||
per-server auth, etc.).
|
||||
- :class:`DefaultMCPToolHandler` — production-grade default backed by
|
||||
:class:`agent_framework.MCPStreamableHTTPTool`.
|
||||
|
||||
Security note: :class:`DefaultMCPToolHandler` performs **no** URL filtering or
|
||||
SSRF protection. Production deployments should supply a custom handler that
|
||||
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
|
||||
.NET design.
|
||||
|
||||
Prompt-injection note: MCP tool outputs flow back into agent conversations
|
||||
(via ``conversationId`` and Tool-role messages emitted by the executor) so
|
||||
they share the same risk surface as ``HttpRequestAction``. Workflow authors
|
||||
must trust the MCP server they invoke.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Content
|
||||
|
||||
__all__ = [
|
||||
"ClientProvider",
|
||||
"DefaultMCPToolHandler",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CACHE_MAX_SIZE = 32
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolInvocation:
|
||||
"""Description of an MCP tool call to be dispatched by a :class:`MCPToolHandler`.
|
||||
|
||||
Mirrors the input parameters of the .NET ``IMcpToolHandler.InvokeToolAsync``
|
||||
method. Field semantics:
|
||||
|
||||
- ``server_url``: Absolute URL of the MCP server. Already evaluated from
|
||||
the YAML expression.
|
||||
- ``server_label``: Optional human-readable label used for diagnostics
|
||||
and as the underlying ``MCPStreamableHTTPTool`` name.
|
||||
- ``tool_name``: Name of the tool to invoke on the MCP server.
|
||||
- ``arguments``: Tool arguments. Already evaluated; values may be any
|
||||
JSON-serialisable Python object (str, int, bool, dict, list, None).
|
||||
- ``headers``: Outbound HTTP headers (e.g. authentication). Empty values
|
||||
are skipped by the executor before construction.
|
||||
- ``connection_name``: Optional Foundry connection name forwarded for
|
||||
handlers that resolve auth/credentials by connection. The default
|
||||
handler does not consume this field.
|
||||
"""
|
||||
|
||||
server_url: str
|
||||
tool_name: str
|
||||
server_label: str | None = None
|
||||
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
connection_name: str | None = None
|
||||
|
||||
|
||||
def _empty_outputs() -> list[Any]:
|
||||
"""Default factory for ``MCPToolResult.outputs``.
|
||||
|
||||
Typed as ``list[Any]`` here to keep the dataclass field's runtime
|
||||
factory simple; the public type on :class:`MCPToolResult` is
|
||||
``list[Content]``.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolResult:
|
||||
"""Response returned by an :class:`MCPToolHandler`.
|
||||
|
||||
Mirrors the .NET ``McpServerToolResultContent`` shape. ``outputs`` is a
|
||||
list of :class:`agent_framework.Content` items as parsed by the MCP
|
||||
transport (TextContent / DataContent / UriContent / etc.).
|
||||
|
||||
On error, ``is_error`` is ``True``, ``error_message`` carries a human
|
||||
readable description, and ``outputs`` typically contains a single
|
||||
``Content.from_text("Error: ...")`` entry for downstream display.
|
||||
"""
|
||||
|
||||
outputs: list[Content] = field(default_factory=_empty_outputs)
|
||||
is_error: bool = False
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MCPToolHandler(Protocol):
|
||||
"""Protocol for MCP tool handlers used by ``InvokeMcpTool``.
|
||||
|
||||
Mirrors :class:`HttpRequestHandler` — declares ONLY the invocation method.
|
||||
Lifecycle methods (``aclose`` / ``__aenter__`` / ``__aexit__``) are NOT
|
||||
part of the Protocol; concrete implementations may add them as
|
||||
appropriate.
|
||||
|
||||
Implementations must be safe to call concurrently from multiple workflow
|
||||
runs. Implementations are responsible for any URL allowlisting, SSRF
|
||||
guards, retry policies, auth resolution, and other policies the workflow
|
||||
author wants applied.
|
||||
"""
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Dispatch ``invocation`` and return the result.
|
||||
|
||||
Args:
|
||||
invocation: Description of the MCP tool call to perform.
|
||||
|
||||
Returns:
|
||||
The :class:`MCPToolResult` carrying the parsed outputs (or an
|
||||
error flag if the tool raised). Implementations SHOULD return a
|
||||
result with ``is_error=True`` rather than raising for transport
|
||||
or tool-level failures, so the workflow can store the message in
|
||||
``output.result`` (matching .NET ``AssignErrorAsync`` behaviour).
|
||||
They MAY raise on unexpected programming errors — these will be
|
||||
propagated unchanged by the executor so they fail loudly.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Internal record stored in the LRU cache."""
|
||||
|
||||
tool: Any # MCPStreamableHTTPTool — typed Any to avoid import at module load
|
||||
owned_httpx_client: httpx.AsyncClient | None
|
||||
|
||||
|
||||
class DefaultMCPToolHandler:
|
||||
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
|
||||
|
||||
Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per
|
||||
``(server_url, server_label, connection_name, headers_hash)`` in a
|
||||
bounded LRU. The cache prevents re-establishing an MCP session for every
|
||||
invocation while ensuring different header sets (auth tokens) cannot
|
||||
share a session — matches the .NET design intent while bounding
|
||||
cardinality. ``server_label`` and ``connection_name`` participate in
|
||||
the key so that callers using ``client_provider`` to dispatch on those
|
||||
fields receive a fresh client per logical connection (see below).
|
||||
Header *names* are lower-cased inside the hash payload only — the
|
||||
headers passed on the wire keep the caller's original casing — so two
|
||||
YAML actions that spell ``Authorization`` differently still share a
|
||||
cache entry.
|
||||
|
||||
Construction modes:
|
||||
|
||||
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
|
||||
instances created lazily per cache entry. Closed by :meth:`aclose`.
|
||||
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client
|
||||
lookup (parity with .NET ``httpClientProvider`` callback). The
|
||||
callback receives the full :class:`MCPToolInvocation` so it can
|
||||
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
|
||||
Returning ``None`` falls back to an internally-created client. Caller
|
||||
supplied clients are NOT closed by :meth:`aclose`.
|
||||
|
||||
.. warning::
|
||||
|
||||
This handler performs **no** URL filtering or SSRF protection. Wrap
|
||||
or replace it with a custom handler in production deployments.
|
||||
|
||||
Args:
|
||||
client_provider: Optional per-server ``httpx.AsyncClient`` provider.
|
||||
cache_max_size: Maximum number of cached MCP clients. When exceeded,
|
||||
the least-recently-used entry is evicted and its client closed
|
||||
(only owned clients are closed; caller-supplied ones are not).
|
||||
Defaults to ``32``.
|
||||
"""
|
||||
|
||||
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
||||
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
|
||||
to the MCP protocol ``tools/list`` discovery operation.
|
||||
|
||||
The constant matches the underlying MCP method name so a single
|
||||
string travels unchanged through host code, YAML, and the protocol
|
||||
wire. When this handler receives an invocation with this name it
|
||||
pages through ``session.list_tools()`` and returns the catalog as a
|
||||
single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
Workflows can reference this name from an ``InvokeMcpTool`` declarative
|
||||
action to introspect a server's tool surface without an extra round-trip
|
||||
from host code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_provider: ClientProvider | None = None,
|
||||
cache_max_size: int = _DEFAULT_CACHE_MAX_SIZE,
|
||||
) -> None:
|
||||
if cache_max_size <= 0:
|
||||
raise ValueError(f"cache_max_size must be positive, got {cache_max_size}")
|
||||
self._client_provider = client_provider
|
||||
self._cache_max_size = cache_max_size
|
||||
self._cache: OrderedDict[tuple[str, str, str, str], _CacheEntry] = OrderedDict()
|
||||
# Outer lock guards the cache + in-flight-future map only — never
|
||||
# held across network I/O.
|
||||
self._cache_lock = asyncio.Lock()
|
||||
# Per-key in-flight futures: while one task is connecting, other
|
||||
# tasks awaiting the same key will await the same future and share
|
||||
# the resulting cache entry.
|
||||
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
|
||||
# Set by ``aclose`` to prevent post-close cache insertions and to
|
||||
# reject new ``invoke_tool`` calls. Once set, never cleared.
|
||||
self._closed = False
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
|
||||
|
||||
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
|
||||
intercepted client-side: instead of being forwarded as a tool call,
|
||||
it is translated to an MCP ``session.list_tools()`` discovery
|
||||
operation (paginated automatically) and returned as a single
|
||||
``TextContent`` containing a JSON tool catalog.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
# Reserved-name args validation runs before connect: rejecting bad
|
||||
# input shouldn't require establishing an MCP session.
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
|
||||
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
entry = await self._get_or_create_entry(invocation)
|
||||
except Exception as exc:
|
||||
# Connect / cache lookup failures surface as tool errors so the
|
||||
# workflow can store them at output.result without crashing.
|
||||
logger.warning(
|
||||
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
|
||||
invocation.server_url,
|
||||
invocation.tool_name,
|
||||
exc,
|
||||
)
|
||||
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
||||
return await self._invoke_list_tools(entry)
|
||||
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
||||
except ToolExecutionException as exc:
|
||||
logger.info(
|
||||
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
|
||||
invocation.tool_name,
|
||||
invocation.server_url,
|
||||
)
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Be defensive about MCP errors that may bubble up without being
|
||||
# wrapped in ToolExecutionException by custom parsers.
|
||||
try:
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
|
||||
raise
|
||||
if isinstance(exc, McpError):
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
raise
|
||||
|
||||
# Defensive normalisation: call_tool is typed ``str | list[Content]``.
|
||||
# Default parser returns list, but custom parse_tool_results may return str.
|
||||
if isinstance(raw, str):
|
||||
outputs: list[Content] = [Content.from_text(raw)]
|
||||
else:
|
||||
outputs = list(raw)
|
||||
return MCPToolResult(outputs=outputs)
|
||||
|
||||
@staticmethod
|
||||
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
|
||||
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
|
||||
|
||||
Pages through ``session.list_tools()`` (mirroring the pagination loop
|
||||
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
|
||||
full catalog as a single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
|
||||
The output shape, property names, and property order are stable so
|
||||
downstream PowerFx expressions can rely on the schema. ``indent=2``
|
||||
produces human-readable JSON for the conversation log;
|
||||
``allow_nan=False`` guards against producing non-conformant JSON
|
||||
``NaN``/``Infinity`` tokens if a misbehaving server returns such
|
||||
values in a schema.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
|
||||
session = getattr(entry.tool, "session", None)
|
||||
if session is None:
|
||||
message = "MCP session is not connected; cannot list tools."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
# Lazy import keeps ``mcp`` types out of module import time.
|
||||
from mcp import types as mcp_types
|
||||
|
||||
collected: list[Any] = []
|
||||
params: mcp_types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
tool_list = await session.list_tools(params=params)
|
||||
collected.extend(tool_list.tools)
|
||||
next_cursor = getattr(tool_list, "nextCursor", None)
|
||||
if not next_cursor:
|
||||
break
|
||||
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
|
||||
|
||||
payload = {
|
||||
"tools": [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.inputSchema,
|
||||
"outputSchema": tool.outputSchema,
|
||||
}
|
||||
for tool in collected
|
||||
],
|
||||
}
|
||||
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all cached MCP clients and the owned httpx clients.
|
||||
|
||||
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
|
||||
``client_provider`` callback) are NOT closed.
|
||||
|
||||
Idempotent — a second call returns immediately. Drains any in-flight
|
||||
``_create_entry`` tasks before returning so their resources are
|
||||
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
|
||||
:meth:`_get_or_create_entry`, close their own entry, and resolve
|
||||
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
|
||||
"""
|
||||
async with self._cache_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
entries = list(self._cache.values())
|
||||
self._cache.clear()
|
||||
inflight_futures = list(self._inflight.values())
|
||||
|
||||
# Wait for in-flight creations to finish their self-cleanup. Each
|
||||
# in-flight task self-closes its entry under the closed-flag branch
|
||||
# in phase 3 and resolves its future with ``RuntimeError``; we
|
||||
# swallow it here because the failure is expected at shutdown.
|
||||
for fut in inflight_futures:
|
||||
try:
|
||||
await fut
|
||||
except BaseException:
|
||||
logger.debug("DefaultMCPToolHandler: in-flight future raised during aclose", exc_info=True)
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
await self._close_entry(entry)
|
||||
|
||||
async def __aenter__(self) -> DefaultMCPToolHandler:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.aclose()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_or_create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
||||
"""Look up (or create) the cached MCP client for this invocation."""
|
||||
key = self._cache_key(
|
||||
invocation.server_url,
|
||||
invocation.server_label,
|
||||
invocation.connection_name,
|
||||
invocation.headers,
|
||||
)
|
||||
|
||||
# Phase 1: check the cache and either claim creation or wait for an
|
||||
# already in-flight creation.
|
||||
creating = False
|
||||
async with self._cache_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("DefaultMCPToolHandler is closed")
|
||||
existing = self._cache.get(key)
|
||||
if existing is not None:
|
||||
self._cache.move_to_end(key)
|
||||
return existing
|
||||
inflight = self._inflight.get(key)
|
||||
if inflight is None:
|
||||
inflight = asyncio.get_running_loop().create_future()
|
||||
self._inflight[key] = inflight
|
||||
creating = True
|
||||
|
||||
if not creating:
|
||||
return await inflight
|
||||
|
||||
# Phase 2: we own creation. Build the entry outside the lock.
|
||||
try:
|
||||
entry = await self._create_entry(invocation)
|
||||
except BaseException as exc:
|
||||
async with self._cache_lock:
|
||||
self._inflight.pop(key, None)
|
||||
if not inflight.done():
|
||||
inflight.set_exception(exc if isinstance(exc, BaseException) else RuntimeError(str(exc)))
|
||||
# Mark the exception retrieved to suppress noisy "Future exception
|
||||
# was never retrieved" warnings when there are no other awaiters
|
||||
# (other awaiters still see the exception through their ``await``).
|
||||
inflight.exception()
|
||||
raise
|
||||
|
||||
# Phase 3: insert with LRU eviction; resolve the in-flight future.
|
||||
# If ``aclose`` ran while we were connecting, ``_closed`` is now
|
||||
# True; don't insert into the cache (it has been drained), close
|
||||
# the just-built entry, and surface the closed-handler error to
|
||||
# all awaiters of the future.
|
||||
evicted: _CacheEntry | None = None
|
||||
duplicate: _CacheEntry | None = None
|
||||
handler_closed = False
|
||||
async with self._cache_lock:
|
||||
self._inflight.pop(key, None)
|
||||
if self._closed:
|
||||
handler_closed = True
|
||||
else:
|
||||
existing = self._cache.get(key)
|
||||
if existing is not None:
|
||||
# Another writer beat us; prefer the existing entry and
|
||||
# discard ours after the lock is released.
|
||||
self._cache.move_to_end(key)
|
||||
duplicate = entry
|
||||
entry = existing
|
||||
else:
|
||||
self._cache[key] = entry
|
||||
self._cache.move_to_end(key)
|
||||
if len(self._cache) > self._cache_max_size:
|
||||
_evicted_key, evicted = self._cache.popitem(last=False)
|
||||
if not inflight.done():
|
||||
inflight.set_result(entry)
|
||||
|
||||
if handler_closed:
|
||||
# Close our orphaned entry; resolve the future with a clear
|
||||
# error so the caller (and any other awaiters) surface a
|
||||
# consistent "handler is closed" failure rather than receiving
|
||||
# an entry we are about to close behind their back.
|
||||
await self._close_entry(entry)
|
||||
err = RuntimeError("DefaultMCPToolHandler is closed")
|
||||
if not inflight.done():
|
||||
inflight.set_exception(err)
|
||||
inflight.exception()
|
||||
raise err
|
||||
if duplicate is not None:
|
||||
await self._close_entry(duplicate)
|
||||
if evicted is not None:
|
||||
await self._close_entry(evicted)
|
||||
return entry
|
||||
|
||||
async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
||||
"""Construct (and connect) a fresh MCP client for ``invocation``."""
|
||||
from agent_framework import MCPStreamableHTTPTool
|
||||
|
||||
provided_client: httpx.AsyncClient | None = None
|
||||
if self._client_provider is not None:
|
||||
provided_client = await self._client_provider(invocation)
|
||||
# Capture headers for this cache entry so the header_provider closure
|
||||
# always returns the same set, regardless of the runtime kwargs.
|
||||
captured_headers = dict(invocation.headers)
|
||||
|
||||
def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
return captured_headers
|
||||
|
||||
tool: Any = MCPStreamableHTTPTool(
|
||||
name=invocation.server_label or "McpClient",
|
||||
url=invocation.server_url,
|
||||
load_prompts=False,
|
||||
http_client=provided_client,
|
||||
header_provider=_header_provider if captured_headers else None,
|
||||
)
|
||||
try:
|
||||
await tool.connect()
|
||||
except BaseException:
|
||||
try:
|
||||
await tool.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True)
|
||||
raise
|
||||
|
||||
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
|
||||
# ``httpx.AsyncClient`` when no caller client was provided AND a
|
||||
# ``header_provider`` was set. We treat any client allocated this
|
||||
# way as owned (closed by the handler). When the caller supplies
|
||||
# one, we never close it.
|
||||
owned_client: httpx.AsyncClient | None = None
|
||||
if provided_client is None:
|
||||
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
|
||||
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
|
||||
|
||||
async def _close_entry(self, entry: _CacheEntry) -> None:
|
||||
"""Close the MCP tool and any owned httpx client."""
|
||||
try:
|
||||
await entry.tool.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
|
||||
if entry.owned_httpx_client is not None:
|
||||
try:
|
||||
await entry.owned_httpx_client.aclose()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(
|
||||
server_url: str,
|
||||
server_label: str | None,
|
||||
connection_name: str | None,
|
||||
headers: dict[str, str] | None,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Build an order-independent cache key for the invocation identity.
|
||||
|
||||
The key includes ``server_label`` and ``connection_name`` so that
|
||||
callers using ``client_provider`` to dispatch on those fields
|
||||
receive a fresh client per logical connection (matches the
|
||||
documented dispatch contract).
|
||||
|
||||
Header *names* are lower-cased inside the hash payload only so
|
||||
that ``Authorization`` and ``authorization`` map to the same
|
||||
cache entry. Header values remain case-sensitive (per RFC 7235).
|
||||
"""
|
||||
if not headers:
|
||||
headers_hash = "0"
|
||||
else:
|
||||
normalized = sorted((k.lower(), v) for k, v in headers.items())
|
||||
payload = json.dumps(normalized, ensure_ascii=False)
|
||||
headers_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return (server_url, server_label or "", connection_name or "", headers_hash)
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Custom PowerFx-like functions for declarative workflows.
|
||||
|
||||
This module provides Python implementations of custom PowerFx functions
|
||||
that are used in declarative workflows but may not be available in the
|
||||
standard PowerFx Python package.
|
||||
|
||||
These functions can be used as fallbacks when PowerFx is not available,
|
||||
or registered with the PowerFx engine when it is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
def message_text(messages: Any) -> str:
|
||||
"""Extract text content from a message or list of messages.
|
||||
|
||||
This is equivalent to the .NET MessageText() function.
|
||||
|
||||
Args:
|
||||
messages: A message object, list of messages, or string
|
||||
|
||||
Returns:
|
||||
The concatenated text content of all messages
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
message_text([{"role": "assistant", "content": "Hello"}])
|
||||
# Returns: 'Hello'
|
||||
"""
|
||||
if messages is None:
|
||||
return ""
|
||||
|
||||
if isinstance(messages, str):
|
||||
return messages
|
||||
|
||||
if isinstance(messages, dict):
|
||||
# Single message object
|
||||
messages_dict = cast(dict[str, Any], messages)
|
||||
content: Any = messages_dict.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
text_attr = getattr(content, "text", None)
|
||||
if text_attr is not None:
|
||||
return str(text_attr)
|
||||
return str(content) if content else ""
|
||||
|
||||
if isinstance(messages, list):
|
||||
# List of messages - concatenate all text
|
||||
texts: list[str] = []
|
||||
message_list = cast(list[Any], messages)
|
||||
for msg in message_list:
|
||||
if isinstance(msg, str):
|
||||
texts.append(msg)
|
||||
elif isinstance(msg, dict):
|
||||
msg_dict = cast(dict[str, Any], msg)
|
||||
msg_content: Any = msg_dict.get("content", "")
|
||||
if isinstance(msg_content, str):
|
||||
texts.append(msg_content)
|
||||
elif msg_content:
|
||||
texts.append(str(msg_content))
|
||||
else:
|
||||
msg_obj: object = msg
|
||||
if hasattr(msg_obj, "content"):
|
||||
msg_obj_content: Any = getattr(msg_obj, "content", None)
|
||||
if isinstance(msg_obj_content, str):
|
||||
texts.append(msg_obj_content)
|
||||
elif (msg_obj_text := getattr(msg_obj_content, "text", None)) is not None:
|
||||
texts.append(str(msg_obj_text))
|
||||
elif msg_obj_content:
|
||||
texts.append(str(msg_obj_content))
|
||||
return " ".join(texts)
|
||||
|
||||
# Try to get text attribute
|
||||
if hasattr(messages, "text"):
|
||||
return str(messages.text)
|
||||
if hasattr(messages, "content"):
|
||||
content_attr: Any = messages.content
|
||||
if isinstance(content_attr, str):
|
||||
return content_attr
|
||||
return str(content_attr) if content_attr else ""
|
||||
|
||||
return str(messages) if messages else ""
|
||||
|
||||
|
||||
def user_message(text: str) -> dict[str, str]:
|
||||
"""Create a user message object.
|
||||
|
||||
This is equivalent to the .NET UserMessage() function.
|
||||
|
||||
Args:
|
||||
text: The text content of the message
|
||||
|
||||
Returns:
|
||||
A message dictionary with role 'user'
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
user_message("Hello")
|
||||
# Returns: {'role': 'user', 'content': 'Hello'}
|
||||
"""
|
||||
return {"role": "user", "content": str(text) if text else ""}
|
||||
|
||||
|
||||
def assistant_message(text: str) -> dict[str, str]:
|
||||
"""Create an assistant message object.
|
||||
|
||||
Args:
|
||||
text: The text content of the message
|
||||
|
||||
Returns:
|
||||
A message dictionary with role 'assistant'
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
assistant_message("Hello")
|
||||
# Returns: {'role': 'assistant', 'content': 'Hello'}
|
||||
"""
|
||||
return {"role": "assistant", "content": str(text) if text else ""}
|
||||
|
||||
|
||||
def agent_message(text: str) -> dict[str, str]:
|
||||
"""Create an agent/assistant message object.
|
||||
|
||||
This is equivalent to the .NET AgentMessage() function.
|
||||
It's an alias for assistant_message() for .NET compatibility.
|
||||
|
||||
Args:
|
||||
text: The text content of the message
|
||||
|
||||
Returns:
|
||||
A message dictionary with role 'assistant'
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
agent_message("Hello")
|
||||
# Returns: {'role': 'assistant', 'content': 'Hello'}
|
||||
"""
|
||||
return {"role": "assistant", "content": str(text) if text else ""}
|
||||
|
||||
|
||||
def system_message(text: str) -> dict[str, str]:
|
||||
"""Create a system message object.
|
||||
|
||||
Args:
|
||||
text: The text content of the message
|
||||
|
||||
Returns:
|
||||
A message dictionary with role 'system'
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
system_message("You are a helpful assistant")
|
||||
# Returns: {'role': 'system', 'content': 'You are a helpful assistant'}
|
||||
"""
|
||||
return {"role": "system", "content": str(text) if text else ""}
|
||||
|
||||
|
||||
def if_func(condition: Any, true_value: Any, false_value: Any = None) -> Any:
|
||||
"""Conditional expression - returns one value or another based on a condition.
|
||||
|
||||
This is equivalent to the PowerFx If() function.
|
||||
|
||||
Args:
|
||||
condition: The condition to evaluate (truthy/falsy)
|
||||
true_value: Value to return if condition is truthy
|
||||
false_value: Value to return if condition is falsy (defaults to None)
|
||||
|
||||
Returns:
|
||||
true_value if condition is truthy, otherwise false_value
|
||||
"""
|
||||
return true_value if condition else false_value
|
||||
|
||||
|
||||
def is_blank(value: Any) -> bool:
|
||||
"""Check if a value is blank (None, empty string, empty list, etc.).
|
||||
|
||||
This is equivalent to the PowerFx IsBlank() function.
|
||||
|
||||
Args:
|
||||
value: The value to check
|
||||
|
||||
Returns:
|
||||
True if the value is considered blank
|
||||
"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return True
|
||||
if isinstance(value, (list, dict)):
|
||||
return len(value) == 0 # type: ignore[reportUnknownArgumentType]
|
||||
return False
|
||||
|
||||
|
||||
def or_func(*args: Any) -> bool:
|
||||
"""Logical OR - returns True if any argument is truthy.
|
||||
|
||||
This is equivalent to the PowerFx Or() function.
|
||||
|
||||
Args:
|
||||
*args: Variable number of values to check
|
||||
|
||||
Returns:
|
||||
True if any argument is truthy
|
||||
"""
|
||||
return any(bool(arg) for arg in args)
|
||||
|
||||
|
||||
def and_func(*args: Any) -> bool:
|
||||
"""Logical AND - returns True if all arguments are truthy.
|
||||
|
||||
This is equivalent to the PowerFx And() function.
|
||||
|
||||
Args:
|
||||
*args: Variable number of values to check
|
||||
|
||||
Returns:
|
||||
True if all arguments are truthy
|
||||
"""
|
||||
return all(bool(arg) for arg in args)
|
||||
|
||||
|
||||
def not_func(value: Any) -> bool:
|
||||
"""Logical NOT - returns the opposite boolean value.
|
||||
|
||||
This is equivalent to the PowerFx Not() function.
|
||||
|
||||
Args:
|
||||
value: The value to negate
|
||||
|
||||
Returns:
|
||||
True if value is falsy, False if truthy
|
||||
"""
|
||||
return not bool(value)
|
||||
|
||||
|
||||
def count_rows(table: Any) -> int:
|
||||
"""Count the number of rows/items in a table/list.
|
||||
|
||||
This is equivalent to the PowerFx CountRows() function.
|
||||
|
||||
Args:
|
||||
table: A list or table-like object
|
||||
|
||||
Returns:
|
||||
The number of rows/items
|
||||
"""
|
||||
if table is None:
|
||||
return 0
|
||||
if isinstance(table, (list, tuple)):
|
||||
return len(cast(list[Any], table))
|
||||
if isinstance(table, dict):
|
||||
return len(cast(dict[str, Any], table))
|
||||
return 0
|
||||
|
||||
|
||||
def first(table: Any) -> Any:
|
||||
"""Get the first item from a table/list.
|
||||
|
||||
This is equivalent to the PowerFx First() function.
|
||||
|
||||
Args:
|
||||
table: A list or table-like object
|
||||
|
||||
Returns:
|
||||
The first item, or None if empty
|
||||
"""
|
||||
if table is None:
|
||||
return None
|
||||
if isinstance(table, (list, tuple)):
|
||||
table_list = cast(list[Any], table)
|
||||
if len(table_list) > 0:
|
||||
return table_list[0]
|
||||
return None
|
||||
|
||||
|
||||
def last(table: Any) -> Any:
|
||||
"""Get the last item from a table/list.
|
||||
|
||||
This is equivalent to the PowerFx Last() function.
|
||||
|
||||
Args:
|
||||
table: A list or table-like object
|
||||
|
||||
Returns:
|
||||
The last item, or None if empty
|
||||
"""
|
||||
if table is None:
|
||||
return None
|
||||
if isinstance(table, (list, tuple)):
|
||||
table_list = cast(list[Any], table)
|
||||
if len(table_list) > 0:
|
||||
return table_list[-1]
|
||||
return None
|
||||
|
||||
|
||||
def find(substring: str | None, text: str | None) -> int | None:
|
||||
"""Find the position of a substring within text.
|
||||
|
||||
This is equivalent to the PowerFx Find() function.
|
||||
Returns None (Blank) if not found, otherwise 1-based index.
|
||||
|
||||
Args:
|
||||
substring: The substring to find
|
||||
text: The text to search in
|
||||
|
||||
Returns:
|
||||
1-based index if found, None (Blank) if not found
|
||||
"""
|
||||
if substring is None or text is None:
|
||||
return None
|
||||
try:
|
||||
index = str(text).find(str(substring))
|
||||
return index + 1 if index >= 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def upper(text: str | None) -> str:
|
||||
"""Convert text to uppercase.
|
||||
|
||||
This is equivalent to the PowerFx Upper() function.
|
||||
|
||||
Args:
|
||||
text: The text to convert
|
||||
|
||||
Returns:
|
||||
Uppercase text
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
return str(text).upper()
|
||||
|
||||
|
||||
def lower(text: str | None) -> str:
|
||||
"""Convert text to lowercase.
|
||||
|
||||
This is equivalent to the PowerFx Lower() function.
|
||||
|
||||
Args:
|
||||
text: The text to convert
|
||||
|
||||
Returns:
|
||||
Lowercase text
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
return str(text).lower()
|
||||
|
||||
|
||||
def concat_strings(*args: Any) -> str:
|
||||
"""Concatenate multiple string arguments.
|
||||
|
||||
This is equivalent to the PowerFx Concat() function for string concatenation.
|
||||
|
||||
Args:
|
||||
*args: Variable number of values to concatenate
|
||||
|
||||
Returns:
|
||||
Concatenated string
|
||||
"""
|
||||
return "".join(str(arg) if arg is not None else "" for arg in args)
|
||||
|
||||
|
||||
def concat_text(table: Any, field: str | None = None, separator: str = "") -> str:
|
||||
"""Concatenate values from a table/list.
|
||||
|
||||
This is equivalent to the PowerFx Concat() function.
|
||||
|
||||
Args:
|
||||
table: A list of items
|
||||
field: Optional field name to extract from each item
|
||||
separator: Separator between values
|
||||
|
||||
Returns:
|
||||
Concatenated string
|
||||
"""
|
||||
if table is None:
|
||||
return ""
|
||||
if not isinstance(table, (list, tuple)):
|
||||
return str(table)
|
||||
|
||||
values: list[str] = []
|
||||
for item in cast(list[Any], table):
|
||||
value: Any = None
|
||||
if field and isinstance(item, dict):
|
||||
item_dict = cast(dict[str, Any], item)
|
||||
value = item_dict.get(field, "")
|
||||
elif field and hasattr(item, field):
|
||||
value = getattr(item, field, "")
|
||||
else:
|
||||
value = item
|
||||
values.append(str(value) if value is not None else "")
|
||||
|
||||
return separator.join(values)
|
||||
|
||||
|
||||
def for_all(table: Any, expression: str, field_mapping: dict[str, str] | None = None) -> list[Any]:
|
||||
"""Apply an expression to each row of a table.
|
||||
|
||||
This is equivalent to the PowerFx ForAll() function.
|
||||
|
||||
Args:
|
||||
table: A list of records
|
||||
expression: A string expression that references item fields
|
||||
field_mapping: Optional dict mapping placeholder names to field names
|
||||
|
||||
Returns:
|
||||
List of results from applying expression to each row
|
||||
|
||||
Note:
|
||||
The expression can use field names directly from the record.
|
||||
For example: ForAll(items, "$" & name & ": " & description)
|
||||
"""
|
||||
if table is None or not isinstance(table, (list, tuple)):
|
||||
return []
|
||||
|
||||
results: list[Any] = []
|
||||
for item in cast(list[Any], table):
|
||||
# If item is a dict, we can directly substitute field values
|
||||
if isinstance(item, dict):
|
||||
item_dict = cast(dict[str, Any], item)
|
||||
# The expression is typically already evaluated by the expression parser
|
||||
# This function primarily handles table iteration
|
||||
# Return the item itself for further processing
|
||||
results.append(item_dict)
|
||||
else:
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_table(table: Any, value: Any, column: str) -> list[Any]:
|
||||
"""Search for rows in a table where a column matches a value.
|
||||
|
||||
This is equivalent to the PowerFx Search() function.
|
||||
|
||||
Args:
|
||||
table: A list of records
|
||||
value: The value to search for
|
||||
column: The column name to search in
|
||||
|
||||
Returns:
|
||||
List of matching records
|
||||
"""
|
||||
if table is None or not isinstance(table, (list, tuple)):
|
||||
return []
|
||||
|
||||
results: list[Any] = []
|
||||
search_value = str(value).lower() if value else ""
|
||||
|
||||
for item in cast(list[Any], table):
|
||||
item_value: Any = None
|
||||
if isinstance(item, dict):
|
||||
item_dict = cast(dict[str, Any], item)
|
||||
item_value = item_dict.get(column, "")
|
||||
elif hasattr(item, column):
|
||||
item_value = getattr(item, column, "")
|
||||
else:
|
||||
continue
|
||||
|
||||
# Case-insensitive contains search
|
||||
if search_value in str(item_value).lower():
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Registry of custom functions
|
||||
CUSTOM_FUNCTIONS: dict[str, Any] = {
|
||||
"MessageText": message_text,
|
||||
"UserMessage": user_message,
|
||||
"AssistantMessage": assistant_message,
|
||||
"AgentMessage": agent_message, # .NET compatibility alias for AssistantMessage
|
||||
"SystemMessage": system_message,
|
||||
"If": if_func,
|
||||
"IsBlank": is_blank,
|
||||
"Or": or_func,
|
||||
"And": and_func,
|
||||
"Not": not_func,
|
||||
"CountRows": count_rows,
|
||||
"First": first,
|
||||
"Last": last,
|
||||
"Find": find,
|
||||
"Upper": upper,
|
||||
"Lower": lower,
|
||||
"Concat": concat_strings,
|
||||
"Search": search_table,
|
||||
"ForAll": for_all,
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""WorkflowState manages PowerFx variables during declarative workflow execution.
|
||||
|
||||
This module provides state management for declarative workflows, handling:
|
||||
- Workflow inputs (read-only)
|
||||
- Turn-scoped variables
|
||||
- Workflow outputs
|
||||
- Agent results and context
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
try:
|
||||
from powerfx import Engine
|
||||
|
||||
_powerfx_engine: Engine | None = Engine()
|
||||
except (ImportError, RuntimeError):
|
||||
# ImportError: powerfx package not installed
|
||||
# RuntimeError: .NET runtime not available or misconfigured
|
||||
_powerfx_engine = None
|
||||
|
||||
logger = logging.getLogger("agent_framework.declarative")
|
||||
|
||||
|
||||
class WorkflowState:
|
||||
"""Manages variables and state during declarative workflow execution.
|
||||
|
||||
WorkflowState provides a unified interface for:
|
||||
|
||||
- Reading workflow inputs (immutable after initialization)
|
||||
- Managing Local-scoped variables that persist across actions
|
||||
- Storing agent results and making them available to subsequent actions
|
||||
- Evaluating PowerFx expressions with the current state as context
|
||||
|
||||
The state is organized into namespaces that mirror the .NET implementation:
|
||||
|
||||
- Workflow.Inputs: Initial inputs to the workflow
|
||||
- Workflow.Outputs: Values to be returned from the workflow
|
||||
- Local: Variables that persist within the current workflow turn
|
||||
- System: System-level variables (ConversationId, LastMessage, etc.)
|
||||
- Agent: Results from the most recent agent invocation
|
||||
- Conversation: Conversation history and messages
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import WorkflowState
|
||||
|
||||
# Initialize with inputs
|
||||
state = WorkflowState(inputs={"query": "Hello", "user_id": "123"})
|
||||
|
||||
# Access inputs (read-only)
|
||||
query = state.get("Workflow.Inputs.query") # "Hello"
|
||||
|
||||
# Set Local-scoped variables
|
||||
state.set("Local.results", [])
|
||||
state.append("Local.results", "item1")
|
||||
state.append("Local.results", "item2")
|
||||
|
||||
# Set workflow outputs
|
||||
state.set("Workflow.Outputs.response", "Completed")
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import WorkflowState
|
||||
|
||||
# PowerFx expression evaluation
|
||||
state = WorkflowState(inputs={"name": "World"})
|
||||
result = state.eval("=Concat('Hello ', Workflow.Inputs.name)")
|
||||
# result: "Hello World"
|
||||
|
||||
# Non-PowerFx strings are returned as-is
|
||||
plain = state.eval("Hello World")
|
||||
# plain: "Hello World"
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_declarative import WorkflowState
|
||||
|
||||
# Working with agent results
|
||||
state = WorkflowState()
|
||||
state.set_agent_result(
|
||||
text="The answer is 42.",
|
||||
messages=[],
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
# Access agent result in subsequent actions
|
||||
response = state.get("Agent.text") # "The answer is 42."
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inputs: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize workflow state with optional inputs.
|
||||
|
||||
Args:
|
||||
inputs: Initial inputs to the workflow. These become available
|
||||
as Workflow.Inputs.* and are immutable after initialization.
|
||||
"""
|
||||
self._inputs: dict[str, Any] = dict(inputs) if inputs else {}
|
||||
self._local: dict[str, Any] = {}
|
||||
self._outputs: dict[str, Any] = {}
|
||||
conversation_id = str(uuid.uuid4())
|
||||
self._system: dict[str, Any] = {
|
||||
"ConversationId": conversation_id,
|
||||
"LastMessage": {"Text": "", "Id": ""},
|
||||
"LastMessageText": "",
|
||||
"LastMessageId": "",
|
||||
"conversations": {
|
||||
conversation_id: {"id": conversation_id, "messages": []},
|
||||
},
|
||||
}
|
||||
self._agent: dict[str, Any] = {}
|
||||
self._conversation: dict[str, Any] = {
|
||||
"messages": [],
|
||||
"history": [],
|
||||
}
|
||||
self._custom: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def inputs(self) -> Mapping[str, Any]:
|
||||
"""Get the workflow inputs (read-only)."""
|
||||
return self._inputs
|
||||
|
||||
@property
|
||||
def outputs(self) -> dict[str, Any]:
|
||||
"""Get the workflow outputs."""
|
||||
return self._outputs
|
||||
|
||||
@property
|
||||
def local(self) -> dict[str, Any]:
|
||||
"""Get the Local-scoped variables."""
|
||||
return self._local
|
||||
|
||||
@property
|
||||
def system(self) -> dict[str, Any]:
|
||||
"""Get the System-scoped variables."""
|
||||
return self._system
|
||||
|
||||
@property
|
||||
def agent(self) -> dict[str, Any]:
|
||||
"""Get the most recent agent result."""
|
||||
return self._agent
|
||||
|
||||
@property
|
||||
def conversation(self) -> dict[str, Any]:
|
||||
"""Get the conversation state."""
|
||||
return self._conversation
|
||||
|
||||
def get(self, path: str, default: Any = None) -> Any:
|
||||
"""Get a value from the state using a dot-notated path.
|
||||
|
||||
Args:
|
||||
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
|
||||
default: Default value if path doesn't exist
|
||||
|
||||
Returns:
|
||||
The value at the path, or default if not found
|
||||
"""
|
||||
parts = path.split(".")
|
||||
if not parts:
|
||||
return default
|
||||
|
||||
namespace = parts[0]
|
||||
remaining = parts[1:]
|
||||
|
||||
# Handle Workflow.Inputs and Workflow.Outputs specially
|
||||
if namespace == "Workflow" and remaining:
|
||||
sub_namespace = remaining[0]
|
||||
remaining = remaining[1:]
|
||||
if sub_namespace == "Inputs":
|
||||
obj: Any = self._inputs
|
||||
elif sub_namespace == "Outputs":
|
||||
obj = self._outputs
|
||||
else:
|
||||
return default
|
||||
elif namespace == "Local":
|
||||
obj = self._local
|
||||
elif namespace == "System":
|
||||
obj = self._system
|
||||
elif namespace == "Agent":
|
||||
obj = self._agent
|
||||
elif namespace == "Conversation":
|
||||
obj = self._conversation
|
||||
else:
|
||||
# Try custom namespace
|
||||
obj = self._custom.get(namespace, default)
|
||||
if obj is default:
|
||||
return default
|
||||
|
||||
# Navigate the remaining path
|
||||
for part in remaining:
|
||||
if isinstance(obj, dict):
|
||||
obj_dict: dict[str, Any] = cast(dict[str, Any], obj)
|
||||
obj = obj_dict.get(part, default)
|
||||
if obj is default:
|
||||
return default
|
||||
elif hasattr(obj, part):
|
||||
obj = getattr(obj, part)
|
||||
else:
|
||||
return default
|
||||
|
||||
return obj
|
||||
|
||||
def set(self, path: str, value: Any) -> None:
|
||||
"""Set a value in the state using a dot-notated path.
|
||||
|
||||
Args:
|
||||
path: Dot-notated path like 'Local.results' or 'Workflow.Outputs.response'
|
||||
value: The value to set
|
||||
|
||||
Raises:
|
||||
ValueError: If attempting to set Workflow.Inputs (which is read-only)
|
||||
"""
|
||||
parts = path.split(".")
|
||||
if not parts:
|
||||
return
|
||||
|
||||
namespace = parts[0]
|
||||
remaining = parts[1:]
|
||||
|
||||
# Handle Workflow.Inputs and Workflow.Outputs specially
|
||||
if namespace == "Workflow":
|
||||
if not remaining:
|
||||
raise ValueError("Cannot set 'Workflow' directly; use 'Workflow.Outputs.*'")
|
||||
sub_namespace = remaining[0]
|
||||
remaining = remaining[1:]
|
||||
if sub_namespace == "Inputs":
|
||||
raise ValueError("Cannot modify Workflow.Inputs - they are read-only")
|
||||
if sub_namespace == "Outputs":
|
||||
target = self._outputs
|
||||
else:
|
||||
raise ValueError(f"Unknown Workflow namespace: {sub_namespace}")
|
||||
elif namespace == "Local":
|
||||
target = self._local
|
||||
elif namespace == "System":
|
||||
target = self._system
|
||||
elif namespace == "Agent":
|
||||
target = self._agent
|
||||
elif namespace == "Conversation":
|
||||
target = self._conversation
|
||||
else:
|
||||
# Create or use custom namespace
|
||||
if namespace not in self._custom:
|
||||
self._custom[namespace] = {}
|
||||
target = self._custom[namespace]
|
||||
|
||||
# Navigate to the parent and set the value
|
||||
if not remaining:
|
||||
# Setting the namespace root itself - this shouldn't happen normally
|
||||
raise ValueError(f"Cannot replace entire namespace '{namespace}'")
|
||||
|
||||
# Navigate to parent, creating dicts as needed
|
||||
for part in remaining[:-1]:
|
||||
if part not in target:
|
||||
target[part] = {}
|
||||
target = target[part]
|
||||
|
||||
# Set the final value
|
||||
target[remaining[-1]] = value
|
||||
|
||||
def append(self, path: str, value: Any) -> None:
|
||||
"""Append a value to a list at the specified path.
|
||||
|
||||
If the path doesn't exist, creates a new list with the value.
|
||||
If the path exists but isn't a list, raises ValueError.
|
||||
|
||||
Args:
|
||||
path: Dot-notated path to a list
|
||||
value: The value to append
|
||||
|
||||
Raises:
|
||||
ValueError: If the existing value is not a list
|
||||
"""
|
||||
existing = self.get(path)
|
||||
if existing is None:
|
||||
self.set(path, [value])
|
||||
elif isinstance(existing, list):
|
||||
existing_list = cast(list[Any], existing)
|
||||
existing_list.append(value)
|
||||
self.set(path, existing_list)
|
||||
else:
|
||||
raise ValueError(f"Cannot append to non-list at path '{path}'")
|
||||
|
||||
def set_agent_result(
|
||||
self,
|
||||
text: str | None = None,
|
||||
messages: list[Any] | None = None,
|
||||
tool_calls: list[Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Set the result from the most recent agent invocation.
|
||||
|
||||
This updates the 'agent' namespace with the agent's response,
|
||||
making it available to subsequent actions via agent.text, agent.messages, etc.
|
||||
|
||||
Args:
|
||||
text: The text content of the agent's response
|
||||
messages: The messages from the agent
|
||||
tool_calls: Any tool calls made by the agent
|
||||
**kwargs: Additional result data
|
||||
"""
|
||||
self._agent = {
|
||||
"text": text,
|
||||
"messages": messages or [],
|
||||
"toolCalls": tool_calls or [],
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
def add_conversation_message(self, message: Any) -> None:
|
||||
"""Add a message to the conversation history.
|
||||
|
||||
Args:
|
||||
message: The message to add (typically a Message or similar)
|
||||
"""
|
||||
self._conversation["messages"].append(message)
|
||||
self._conversation["history"].append(message)
|
||||
|
||||
def to_powerfx_symbols(self) -> dict[str, Any]:
|
||||
"""Convert the current state to a PowerFx symbols dictionary.
|
||||
|
||||
Returns:
|
||||
A dictionary suitable for passing to PowerFx Engine.eval()
|
||||
"""
|
||||
symbols = {
|
||||
"Workflow": {
|
||||
"Inputs": dict(self._inputs),
|
||||
"Outputs": dict(self._outputs),
|
||||
},
|
||||
"Local": dict(self._local),
|
||||
"System": dict(self._system),
|
||||
"Agent": dict(self._agent),
|
||||
"Conversation": dict(self._conversation),
|
||||
# Also expose inputs at top level for backward compatibility with =inputs.X syntax
|
||||
"inputs": dict(self._inputs),
|
||||
**self._custom,
|
||||
}
|
||||
# Debug log the Local symbols to help diagnose type issues
|
||||
if self._local:
|
||||
for key, value in self._local.items():
|
||||
logger.debug(
|
||||
f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
|
||||
f"value_preview={str(value)[:100] if value else None}"
|
||||
)
|
||||
return symbols
|
||||
|
||||
def eval(self, expression: str) -> Any:
|
||||
"""Evaluate a PowerFx expression with the current state.
|
||||
|
||||
Expressions starting with '=' are evaluated as PowerFx.
|
||||
Other strings are returned as-is (after variable interpolation if applicable).
|
||||
|
||||
Args:
|
||||
expression: The expression to evaluate
|
||||
|
||||
Returns:
|
||||
The evaluated result, or the original expression if not a PowerFx expression
|
||||
"""
|
||||
if not expression:
|
||||
return expression
|
||||
|
||||
if not expression.startswith("="):
|
||||
return expression
|
||||
|
||||
# Strip the leading '=' for evaluation
|
||||
formula = expression[1:]
|
||||
|
||||
if _powerfx_engine is not None:
|
||||
# Try PowerFx evaluation first
|
||||
try:
|
||||
symbols = self.to_powerfx_symbols()
|
||||
return _powerfx_engine.eval(formula, symbols=symbols)
|
||||
except Exception as exc:
|
||||
logger.warning(f"PowerFx evaluation failed for '{expression[:50]}': {exc}")
|
||||
# Fall through to simple evaluation
|
||||
|
||||
# Fallback: Simple expression evaluation using custom functions
|
||||
return self._eval_simple(formula)
|
||||
|
||||
def _eval_simple(self, formula: str) -> Any:
|
||||
"""Simple expression evaluation when PowerFx is not available.
|
||||
|
||||
Supports:
|
||||
- Variable references: Local.X, System.X, Workflow.Inputs.X
|
||||
- Simple function calls: IsBlank(x), Find(a, b), etc.
|
||||
- Simple comparisons: x < 4, x = "value"
|
||||
- Logical operators: And, Or, Not, ||, !
|
||||
- Negation: !expression
|
||||
|
||||
Args:
|
||||
formula: The formula to evaluate (without leading '=')
|
||||
|
||||
Returns:
|
||||
The evaluated result
|
||||
"""
|
||||
from ._powerfx_functions import CUSTOM_FUNCTIONS
|
||||
|
||||
formula = formula.strip()
|
||||
|
||||
# Handle negation prefix
|
||||
if formula.startswith("!"):
|
||||
inner = formula[1:].strip()
|
||||
result = self._eval_simple(inner)
|
||||
return not bool(result)
|
||||
|
||||
# Handle Not() function
|
||||
if formula.startswith("Not(") and formula.endswith(")"):
|
||||
inner = formula[4:-1].strip()
|
||||
result = self._eval_simple(inner)
|
||||
return not bool(result)
|
||||
|
||||
# Handle function calls
|
||||
for func_name, func in CUSTOM_FUNCTIONS.items():
|
||||
if formula.startswith(f"{func_name}(") and formula.endswith(")"):
|
||||
args_str = formula[len(func_name) + 1 : -1]
|
||||
# Simple argument parsing (doesn't handle nested calls well)
|
||||
args = self._parse_function_args(args_str)
|
||||
evaluated_args = [self._eval_simple(arg) if isinstance(arg, str) else arg for arg in args]
|
||||
try:
|
||||
return func(*evaluated_args)
|
||||
except Exception as e:
|
||||
logger.warning(f"Function {func_name} failed: {e}")
|
||||
return formula
|
||||
|
||||
# Handle And operator
|
||||
if " And " in formula:
|
||||
parts = formula.split(" And ", 1)
|
||||
left = self._eval_simple(parts[0])
|
||||
right = self._eval_simple(parts[1])
|
||||
return bool(left) and bool(right)
|
||||
|
||||
# Handle Or operator (||)
|
||||
if " || " in formula or " Or " in formula:
|
||||
parts = formula.split(" || ", 1) if " || " in formula else formula.split(" Or ", 1)
|
||||
left = self._eval_simple(parts[0])
|
||||
right = self._eval_simple(parts[1])
|
||||
return bool(left) or bool(right)
|
||||
|
||||
# Handle comparison operators
|
||||
for op in [" < ", " > ", " <= ", " >= ", " <> ", " = "]:
|
||||
if op in formula:
|
||||
parts = formula.split(op, 1)
|
||||
left = self._eval_simple(parts[0].strip())
|
||||
right = self._eval_simple(parts[1].strip())
|
||||
if op == " < ":
|
||||
return left < right
|
||||
if op == " > ":
|
||||
return left > right
|
||||
if op == " <= ":
|
||||
return left <= right
|
||||
if op == " >= ":
|
||||
return left >= right
|
||||
if op == " <> ":
|
||||
return left != right
|
||||
if op == " = ":
|
||||
return left == right
|
||||
|
||||
# Handle arithmetic operators
|
||||
if " + " in formula:
|
||||
parts = formula.split(" + ", 1)
|
||||
left = self._eval_simple(parts[0].strip())
|
||||
right = self._eval_simple(parts[1].strip())
|
||||
# Treat None as 0 for arithmetic (PowerFx behavior)
|
||||
if left is None:
|
||||
left = 0
|
||||
if right is None:
|
||||
right = 0
|
||||
# Try numeric addition first, fall back to string concat
|
||||
try:
|
||||
return float(left) + float(right)
|
||||
except (ValueError, TypeError):
|
||||
return str(left) + str(right)
|
||||
|
||||
if " - " in formula:
|
||||
parts = formula.split(" - ", 1)
|
||||
left = self._eval_simple(parts[0].strip())
|
||||
right = self._eval_simple(parts[1].strip())
|
||||
# Treat None as 0 for arithmetic (PowerFx behavior)
|
||||
if left is None:
|
||||
left = 0
|
||||
if right is None:
|
||||
right = 0
|
||||
try:
|
||||
return float(left) - float(right)
|
||||
except (ValueError, TypeError):
|
||||
return formula
|
||||
|
||||
# Handle multiplication
|
||||
if " * " in formula:
|
||||
parts = formula.split(" * ", 1)
|
||||
left = self._eval_simple(parts[0].strip())
|
||||
right = self._eval_simple(parts[1].strip())
|
||||
# Treat None as 0 for arithmetic (PowerFx behavior)
|
||||
if left is None:
|
||||
left = 0
|
||||
if right is None:
|
||||
right = 0
|
||||
try:
|
||||
return float(left) * float(right)
|
||||
except (ValueError, TypeError):
|
||||
return formula
|
||||
|
||||
# Handle division with div-by-zero protection
|
||||
if " / " in formula:
|
||||
parts = formula.split(" / ", 1)
|
||||
left = self._eval_simple(parts[0].strip())
|
||||
right = self._eval_simple(parts[1].strip())
|
||||
# Treat None as 0 for arithmetic (PowerFx behavior)
|
||||
if left is None:
|
||||
left = 0
|
||||
if right is None:
|
||||
right = 0
|
||||
try:
|
||||
right_float = float(right)
|
||||
if right_float == 0:
|
||||
# PowerFx returns Error for division by zero; we return None (Blank)
|
||||
logger.warning(f"Division by zero in expression: {formula}")
|
||||
return None
|
||||
return float(left) / right_float
|
||||
except (ValueError, TypeError):
|
||||
return formula
|
||||
|
||||
# Handle string literals
|
||||
if (formula.startswith('"') and formula.endswith('"')) or (formula.startswith("'") and formula.endswith("'")):
|
||||
return formula[1:-1]
|
||||
|
||||
# Handle numeric literals
|
||||
try:
|
||||
if "." in formula:
|
||||
return float(formula)
|
||||
return int(formula)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle boolean literals
|
||||
if formula.lower() == "true":
|
||||
return True
|
||||
if formula.lower() == "false":
|
||||
return False
|
||||
|
||||
# Handle variable references
|
||||
if "." in formula:
|
||||
# For known namespaces, return None if not found (PowerFx semantics)
|
||||
# rather than the formula string
|
||||
if formula.startswith(("Local.", "Workflow.", "Agent.", "Conversation.", "System.")):
|
||||
return self.get(formula)
|
||||
not_found = object()
|
||||
value = self.get(formula, default=not_found)
|
||||
if value is not not_found:
|
||||
return value
|
||||
|
||||
# Return the formula as-is if we can't evaluate it
|
||||
return formula
|
||||
|
||||
def _parse_function_args(self, args_str: str) -> list[str]:
|
||||
"""Parse function arguments, handling nested parentheses and strings.
|
||||
|
||||
Args:
|
||||
args_str: The argument string (without outer parentheses)
|
||||
|
||||
Returns:
|
||||
List of argument strings
|
||||
"""
|
||||
args: list[str] = []
|
||||
current = ""
|
||||
depth = 0
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
for char in args_str:
|
||||
if char in ('"', "'") and not in_string:
|
||||
in_string = True
|
||||
string_char = char
|
||||
current += char
|
||||
elif char == string_char and in_string:
|
||||
in_string = False
|
||||
string_char = None
|
||||
current += char
|
||||
elif char == "(" and not in_string:
|
||||
depth += 1
|
||||
current += char
|
||||
elif char == ")" and not in_string:
|
||||
depth -= 1
|
||||
current += char
|
||||
elif char == "," and depth == 0 and not in_string:
|
||||
args.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += char
|
||||
|
||||
if current.strip():
|
||||
args.append(current.strip())
|
||||
|
||||
return args
|
||||
|
||||
def eval_if_expression(self, value: Any) -> Any:
|
||||
"""Evaluate a value if it's a PowerFx expression, otherwise return as-is.
|
||||
|
||||
This is a convenience method that handles both expressions and literals.
|
||||
|
||||
Args:
|
||||
value: A value that may or may not be a PowerFx expression
|
||||
|
||||
Returns:
|
||||
The evaluated result if it's an expression, or the original value
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return self.eval(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): self.eval_if_expression(v) for k, v in value.items()} # type: ignore[reportUnknownVariableType]
|
||||
if isinstance(value, list):
|
||||
return [self.eval_if_expression(item) for item in value] # type: ignore[reportUnknownVariableType]
|
||||
return value
|
||||
|
||||
def reset_local(self) -> None:
|
||||
"""Reset Local-scoped variables for a new turn.
|
||||
|
||||
This clears the Local namespace while preserving other state.
|
||||
"""
|
||||
self._local.clear()
|
||||
|
||||
def reset_agent(self) -> None:
|
||||
"""Reset the agent result for a new agent invocation."""
|
||||
self._agent.clear()
|
||||
|
||||
def clone(self) -> WorkflowState:
|
||||
"""Create a shallow copy of the state.
|
||||
|
||||
Returns:
|
||||
A new WorkflowState with copied data
|
||||
"""
|
||||
import copy
|
||||
|
||||
new_state = WorkflowState()
|
||||
new_state._inputs = copy.copy(self._inputs)
|
||||
new_state._local = copy.copy(self._local)
|
||||
new_state._system = copy.copy(self._system)
|
||||
new_state._outputs = copy.copy(self._outputs)
|
||||
new_state._agent = copy.copy(self._agent)
|
||||
new_state._conversation = copy.copy(self._conversation)
|
||||
new_state._custom = copy.copy(self._custom)
|
||||
return new_state
|
||||
Reference in New Issue
Block a user