Revamped sample to address PR comments.

This commit is contained in:
Peter Ibekwe
2026-05-22 08:58:19 -07:00
Unverified
parent 894b2c6ce0
commit 6ea7b45290
8 changed files with 118 additions and 233 deletions
@@ -63,24 +63,19 @@ logger = logging.getLogger(__name__)
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
_EMPTY_ENV_VALUES: Mapping[str, str] = MappingProxyType({})
_EMPTY_ENV_REFERENCES: frozenset[str] = frozenset()
@dataclass(frozen=True)
class DeclarativeEnvConfig:
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
Mirrors the .NET ``IConfiguration``-driven environment binding in
``Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs``.
The configuration values are always exposed under ``Env.<name>`` (matching
``IConfiguration`` semantics), and ``os.environ`` is consulted only when
``restrict_to_configuration`` is ``False`` and the YAML literally
references the name in a PowerFx expression.
Configuration values are always exposed under ``Env.<name>``;
``os.environ`` is consulted only when ``restrict_to_configuration``
is ``False`` AND the YAML literally references the name in a PowerFx
expression (the allowlist enforced via ``referenced_names``).
Attributes:
values: Caller-supplied configuration values resolved by name when
the workflow YAML references ``=Env.NAME``. Always exposed in
values: Caller-supplied configuration resolved by name when the
workflow YAML references ``=Env.NAME``. Always exposed in
the ``Env`` symbol regardless of ``restrict_to_configuration``.
restrict_to_configuration: When ``True`` (default), the ``Env``
symbol is populated exclusively from ``values``; ``os.environ``
@@ -93,12 +88,35 @@ class DeclarativeEnvConfig:
unrelated environment variables never enter the PowerFx scope.
"""
values: Mapping[str, str] = field(default_factory=lambda: _EMPTY_ENV_VALUES)
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
restrict_to_configuration: bool = True
referenced_names: frozenset[str] = _EMPTY_ENV_REFERENCES
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
def __post_init__(self) -> None:
# Defensive snapshots so the frozen guarantee extends to the
# contents of ``values`` / ``referenced_names``: caller mutations
# to the original objects after construction cannot leak into
# ``resolve()``.
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
_EMPTY_ENV_CONFIG = DeclarativeEnvConfig()
def resolve(self) -> dict[str, str]:
"""Return the resolved ``Env`` symbol mapping for the workflow.
Configuration values are always included (stringified).
``os.environ`` is consulted only when ``restrict_to_configuration``
is ``False`` and the name appears in ``referenced_names``, so
unrelated environment variables never enter the PowerFx scope.
Configuration values always win over the environment fallback.
"""
resolved = {name: str(value) for name, value in self.values.items()}
if self.restrict_to_configuration:
return resolved
for name in self.referenced_names.difference(resolved):
env_value = os.environ.get(name)
if env_value is not None:
resolved[name] = env_value
return resolved
def discover_env_references(node: Any) -> set[str]:
@@ -248,7 +266,7 @@ class DeclarativeWorkflowState:
- Conversation: Conversation history
"""
def __init__(self, state: State, env_config: DeclarativeEnvConfig = _EMPTY_ENV_CONFIG):
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
"""Initialize with a State instance.
Args:
@@ -259,7 +277,7 @@ class DeclarativeWorkflowState:
matching the safe default of the :class:`WorkflowFactory`.
"""
self._state = state
self._env_config = env_config
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
"""Initialize the declarative state with inputs.
@@ -798,25 +816,12 @@ class DeclarativeWorkflowState:
# Custom namespaces
**state_data.get("Custom", {}),
}
# Populate the ``Env`` symbol from the workflow-level
# :class:`DeclarativeEnvConfig`. Caller-supplied configuration
# values are always exposed (matching .NET ``IConfiguration``
# semantics); ``os.environ`` is consulted only when
# ``restrict_to_configuration`` is ``False`` and the YAML explicitly
# references the name in a PowerFx expression. When both sources
# produce no values the ``Env`` symbol is omitted entirely so
# ``=Env.X`` resolves to the literal expression string (preserving
# the legacy "unbound identifier" fallback behaviour).
env_bound: dict[str, str] = {}
for name, value in self._env_config.values.items():
env_bound[name] = str(value)
if not self._env_config.restrict_to_configuration:
for name in self._env_config.referenced_names:
if name in env_bound:
continue
env_value = os.environ.get(name)
if env_value is not None:
env_bound[name] = env_value
# Resolve the ``Env`` symbol from the workflow-level
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
# ``os.environ`` allowlist produce no entries the symbol is
# omitted so ``=Env.X`` falls back to the literal expression
# string (preserving the legacy "unbound identifier" behaviour).
env_bound = self._env_config.resolve()
if env_bound:
symbols["Env"] = env_bound
# Debug log the Local symbols to help diagnose type issues
@@ -976,7 +981,7 @@ class DeclarativeActionExecutor(Executor):
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
self._declarative_env_config: DeclarativeEnvConfig = _EMPTY_ENV_CONFIG
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
# Manually register handlers after initialization
self._handlers = {}
@@ -22,7 +22,6 @@ from agent_framework import (
)
from ._declarative_base import (
_EMPTY_ENV_CONFIG, # type: ignore[reportPrivateUsage]
ConditionResult,
DeclarativeActionExecutor,
DeclarativeEnvConfig,
@@ -178,7 +177,7 @@ class DeclarativeWorkflowBuilder:
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else _EMPTY_ENV_CONFIG
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
@@ -124,14 +124,12 @@ class WorkflowFactory:
or auth/connection resolution.
configuration: Optional mapping that populates the PowerFx ``Env``
symbol referenced from workflow YAML expressions (e.g.
``=Env.MY_KEY``). Mirrors the .NET ``IConfiguration``-based
pattern in
``dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs``.
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.
``=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
@@ -198,11 +198,12 @@ class DefaultMCPToolHandler:
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
to the MCP protocol ``tools/list`` discovery operation.
Mirrors the .NET ``DefaultMcpToolHandler.ListToolsToolName`` public
constant for cross-language discoverability. 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}, ...]}``.
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.
@@ -237,9 +238,7 @@ class DefaultMCPToolHandler:
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. Matches the .NET
``DefaultMcpToolHandler`` behaviour so the same YAML works
cross-language.
``TextContent`` containing a JSON tool catalog.
"""
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
@@ -328,13 +327,12 @@ class DefaultMCPToolHandler:
full catalog as a single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
The output shape, property names, and property order match
``DefaultMcpToolHandler.SerializeToolsList`` in the .NET reference
implementation so the same workflow YAML can consume the result
cross-language. ``indent=2`` matches ``Utf8JsonWriter`` ``Indented``
mode; ``allow_nan=False`` guards against producing non-conformant
JSON ``NaN``/``Infinity`` tokens if a misbehaving server returns
such values in a schema.
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
@@ -644,8 +644,8 @@ class TestListTools:
}
@pytest.mark.asyncio
async def test_list_tools_property_order_matches_dotnet(self) -> None:
"""The JSON property order must match .NET SerializeToolsList: name, description, inputSchema, outputSchema."""
async def test_list_tools_property_order_is_stable(self) -> None:
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
@@ -662,7 +662,7 @@ class TestListTools:
@pytest.mark.asyncio
async def test_list_tools_indented_output(self) -> None:
"""Output is indented with 2-space indent (matches .NET ``Indented=true``)."""
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
@@ -774,6 +774,6 @@ class TestListTools:
assert result.is_error is False
def test_class_attribute_value(self) -> None:
# Constant name MUST be the MCP protocol method name for cross-language parity
# with .NET ``DefaultMcpToolHandler.ListToolsToolName``.
# Constant must equal the MCP protocol method name so a single
# string travels unchanged through host code, YAML, and the wire.
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"