mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Revamped sample to address PR comments.
This commit is contained in:
+41
-36
@@ -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 = {}
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+13
-15
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user