Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion python/packages/declarative/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ YAML/JSON-based declarative agent and workflow definitions.

- **`AgentFactory`** - Creates agents from declarative definitions
- **`WorkflowFactory`** - Creates workflows from declarative definitions
- **`WorkflowState`** - State management for declarative workflows
- **`WorkflowState`** - Standalone state management and expression evaluation; factory-created workflows use the internal, checkpoint-backed `DeclarativeWorkflowState` instead
- **`ProviderTypeMapping`** - Maps provider types to implementations
- **`HttpRequestHandler`** / **`DefaultHttpRequestHandler`** - Pluggable HTTP transport for the `HttpRequestAction` declarative action (configured via `WorkflowFactory(http_request_handler=...)`)
- **`MCPToolHandler`** / **`DefaultMCPToolHandler`** - Pluggable MCP transport for the `InvokeMcpTool` declarative action (configured via `WorkflowFactory(mcp_tool_handler=...)`)
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** / **`DeclarativeWorkflowError`** / **`DeclarativeActionError`** - Error types

Both state classes validate object attribute names but leave dictionary keys unchanged.

## MCP Handler Lifetimes

`DefaultMCPToolHandler` caches/coalesces sessions only without a `client_provider`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

from ._errors import DeclarativeWorkflowError
from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage]

try:
from powerfx import Engine
Expand All @@ -66,9 +67,6 @@

_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")

# Allowed identifier shape for object-attribute steps in declarative state paths
_SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")


def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
"""Return the end of an ordinary quoted token or comment, or start if neither."""
Expand Down Expand Up @@ -500,7 +498,7 @@ def get(self, path: str, default: Any = None) -> Any:
return default
else:
# Attribute access is only allowed for safe declarative identifiers.
if not _SAFE_PATH_SEGMENT_RE.match(part):
if not _is_safe_path_segment(part):
logger.warning(
"DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
part,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from typing import Any, cast

from ._powerfx_limits import _PowerFxStateLimitError, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage]

try:
from powerfx import Engine
Expand All @@ -31,7 +32,10 @@


class WorkflowState:
"""Manages variables and state during declarative workflow execution.
"""Manages standalone workflow state and expression evaluation.

Workflows created by ``WorkflowFactory`` use a separate, checkpoint-backed
``DeclarativeWorkflowState`` implementation.

WorkflowState provides a unified interface for:

Expand Down Expand Up @@ -161,12 +165,16 @@ def conversation(self) -> dict[str, Any]:
def get(self, path: str, default: Any = None) -> Any:
"""Get a value from the state using a dot-notated path.

Dict-keyed segments may use arbitrary string keys. Segments resolved
through object-attribute access must match ``[A-Za-z][A-Za-z0-9_]*``;
other shapes return ``default`` without accessing the attribute.

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
The value at the path, or default if not found or unreachable
"""
parts = path.split(".")
if not parts:
Expand Down Expand Up @@ -206,10 +214,18 @@ def get(self, path: str, default: Any = None) -> Any:
obj = obj_dict.get(part, default)
if obj is default:
return default
elif hasattr(obj, part):
obj = getattr(obj, part)
else:
return default
if not _is_safe_path_segment(part):
logger.warning(
"WorkflowState.get: rejecting attribute segment %r in path %r",
part,
path,
)
return default
if hasattr(obj, part):
obj = getattr(obj, part)
else:
return default

return obj

Expand Down Expand Up @@ -372,7 +388,10 @@ 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).
Other strings are returned as-is. If PowerFx is unavailable or raises
an ordinary evaluation error, the simple evaluator is used instead.
Its state references follow :meth:`get`'s object-member rules.
State-budget errors propagate without falling back.

Args:
expression: The expression to evaluate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.

"""Shared identifier policy for object-attribute steps in workflow state paths."""

import regex

_SAFE_PATH_SEGMENT_RE = regex.compile(r"[A-Za-z][A-Za-z0-9_]*")


def _is_safe_path_segment(segment: str) -> bool: # pyright: ignore[reportUnusedFunction]
"""Return whether an object-attribute segment is a declarative identifier."""
return _SAFE_PATH_SEGMENT_RE.fullmatch(segment) is not None
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false

"""Path-segment validation tests for DeclarativeWorkflowState.
"""Path-segment validation tests for workflow state.

Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
placeholders in ``interpolate_string`` are subject to three distinct rules
that this module pins:
For DeclarativeWorkflowState, path segments handed to ``get``/``set``/``append``
and ``{Variable.Path}`` placeholders in ``interpolate_string`` are subject to
three distinct rules that this module pins:

- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
Expand All @@ -21,6 +21,9 @@
- **Dict-keyed segments** — segments that resolve via dict lookup because the
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).

Standalone WorkflowState shares the object-member rule, but retains its
existing empty-dictionary-key behavior.
"""

import logging
Expand All @@ -29,8 +32,11 @@
from unittest.mock import MagicMock

import pytest
from agent_framework._workflows._state import State

from agent_framework_declarative import WorkflowState
from agent_framework_declarative._workflows import DeclarativeWorkflowState
from agent_framework_declarative._workflows import _declarative_base as base

try:
import powerfx # noqa: F401
Expand Down Expand Up @@ -81,6 +87,126 @@ class _PlainObj:
text: str = "hi"


@dataclass
class _MemberRecord(_PlainObj):
_private: str = "private-marker"


class TestStateMemberAccess:
"""Both state implementations distinguish object members from dictionary keys."""

@pytest.fixture(params=["standalone", "modern"])
def member_state(self, request: pytest.FixtureRequest) -> WorkflowState | DeclarativeWorkflowState:
if request.param == "standalone":
return WorkflowState(inputs={"record": _MemberRecord()})
state = DeclarativeWorkflowState(State())
state.initialize({"record": _MemberRecord()})
return state

@pytest.mark.parametrize("name", ["_private", "__marker__", "text-other", "1text", "text other", "text\n"])
def test_rejects_existing_invalid_members(
self, member_state: WorkflowState | DeclarativeWorkflowState, name: str
) -> None:
record = _PlainObj()
setattr(record, name, "member-marker")
member_state.set("Local.record", record)
default = object()

assert member_state.get(f"Local.record.{name}", default) is default

def test_rejects_before_reading_attribute(
self, member_state: WorkflowState | DeclarativeWorkflowState, caplog: pytest.LogCaptureFixture
) -> None:
class Record:
@property
def _private(self) -> str:
raise AssertionError("Rejected attribute was read")

member_state.set("Local.record", Record())
default = object()
with caplog.at_level(logging.WARNING):
assert member_state.get("Local.record._private", default) is default

assert any("rejecting attribute segment" in record.message for record in caplog.records)

@pytest.mark.parametrize(
"namespace", ["Workflow.Inputs", "Workflow.Outputs", "Local", "System", "Agent", "Conversation", "Custom"]
)
def test_member_rule_applies_across_namespaces(
self, member_state: WorkflowState | DeclarativeWorkflowState, namespace: str
) -> None:
if namespace != "Workflow.Inputs":
member_state.set(f"{namespace}.record", _MemberRecord())

assert member_state.get(f"{namespace}.record._private") is None
assert member_state.get(f"{namespace}.record.text") == "hi"

def test_mixed_dictionary_and_attribute_steps(self, member_state: WorkflowState | DeclarativeWorkflowState) -> None:
class Record:
class_value = "class-marker"
_private = "private-marker"

def __init__(self) -> None:
self.public_value = "instance-marker"
self.child = {"nested": _PlainObj()}

member_state.set("Local.bag", {"record": Record()})
member_state.set("Local.nested", {"record": _MemberRecord(_private="nested-marker")})

assert member_state.get("Local.bag.record.class_value") == "class-marker"
assert member_state.get("Local.bag.record.public_value") == "instance-marker"
assert member_state.get("Local.bag.record.child.nested.text") == "hi"
assert member_state.get("Local.bag.record._private") is None
assert member_state.get("Local.nested.record._private") is None
default = object()
assert member_state.get("Local.bag.record.missing", default) is default

@pytest.mark.parametrize("key", ["_private", "__marker__", "text-other", "123", "text other", "text\n"])
def test_dictionary_keys_are_not_member_identifiers(
self, member_state: WorkflowState | DeclarativeWorkflowState, key: str
) -> None:
member_state.set("Local.bag", {key: "dictionary-marker"})
assert member_state.get(f"Local.bag.{key}") == "dictionary-marker"

@pytest.mark.parametrize("mode", ["absent", "error"])
def test_modern_engine_errors_do_not_fall_back(self, monkeypatch: pytest.MonkeyPatch, mode: str) -> None:
state = DeclarativeWorkflowState(State())
state.initialize()
state.set("Local.record", _PlainObj())
get = MagicMock(side_effect=AssertionError("Unexpected member lookup"))
monkeypatch.setattr(state, "get", get)
if mode == "absent":
monkeypatch.setattr(base, "Engine", None)
with pytest.raises(RuntimeError, match="PowerFx is not available"):
state.eval("=Local.record.text")
else:
engine = MagicMock()
engine.eval.side_effect = ValueError("synthetic engine error")
monkeypatch.setattr(base, "Engine", MagicMock(return_value=engine))
with pytest.raises(ValueError, match="synthetic engine error"):
state.eval("=Local.record.text")
get.assert_not_called()

@_requires_powerfx
def test_modern_projection_preserves_instance_data(self) -> None:
class Record:
_class_value = "class-marker"

def __init__(self) -> None:
self._declared = "instance-marker"
self.public_value = "public-marker"

state = DeclarativeWorkflowState(State())
state.initialize()
state.set("Local.record", Record())

assert state.get("Local.record._declared") is None
assert state.get("Local.record._class_value") is None
assert state.eval("=Local.record.public_value") == "public-marker"
assert state.eval("=Local.record._declared") == "instance-marker"
assert state.eval("=Local.record._class_value") is None


# ---------------------------------------------------------------------------
# get(): invalid paths return default
# ---------------------------------------------------------------------------
Expand Down
50 changes: 50 additions & 0 deletions python/packages/declarative/tests/test_graph_executors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pytest
from agent_framework import WorkflowInvocationKwargs
from agent_framework._workflows._state import State

try:
import powerfx # noqa: F401
Expand All @@ -17,11 +18,13 @@

_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")

from agent_framework_declarative import WorkflowFactory, WorkflowState # noqa: E402
from agent_framework_declarative._workflows import ( # noqa: E402
ALL_ACTION_EXECUTORS,
DECLARATIVE_STATE_KEY,
ActionComplete,
ActionTrigger,
DeclarativeActionExecutor,
DeclarativeWorkflowBuilder,
DeclarativeWorkflowState,
ForeachInitExecutor,
Expand All @@ -31,6 +34,53 @@
)


class TestFactoryStateMemberRoutes:
"""Factory-created workflows use modern state for expressions and templates."""

@_requires_powerfx
async def test_factory_uses_modern_member_resolution(self, monkeypatch: pytest.MonkeyPatch) -> None:
class Record:
_private = "private-marker"

def __init__(self) -> None:
self.public_value = "public-marker"

forbidden = MagicMock(side_effect=AssertionError("Standalone state used by factory"))
monkeypatch.setattr(WorkflowState, "get", forbidden)
monkeypatch.setattr(WorkflowState, "eval", forbidden)
seen: list[type[DeclarativeWorkflowState]] = []
original = DeclarativeActionExecutor._get_state

def track(executor: DeclarativeActionExecutor, store: State) -> DeclarativeWorkflowState:
state = original(executor, store)
seen.append(type(state))
return state

monkeypatch.setattr(DeclarativeActionExecutor, "_get_state", track)
workflow = WorkflowFactory().create_workflow_from_yaml("""
kind: Workflow
trigger:
kind: OnConversationStart
id: member_routes
actions:
- kind: SendActivity
id: public_expression
activity: =Workflow.Inputs.record.public_value
- kind: SendActivity
id: private_expression
activity: =Workflow.Inputs.record._private
- kind: SendActivity
id: private_template
activity: "template:{Workflow.Inputs.record._private}"
""")

result = await workflow.run({"record": Record()})

assert result.get_outputs() == ["public-marker", "template:"]
assert seen and all(kind is DeclarativeWorkflowState for kind in seen)
forbidden.assert_not_called()


class TestDeclarativeWorkflowState:
"""Tests for DeclarativeWorkflowState."""

Expand Down
Loading
Loading