agent-framework-declarative 1.3.0__tar.gz → 1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/PKG-INFO +5 -4
  2. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/README.md +2 -1
  3. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_declarative_base.py +17 -80
  4. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_basic.py +2 -1
  5. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_mcp.py +3 -3
  6. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_tools.py +26 -3
  7. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_mcp_handler.py +24 -10
  8. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_powerfx_functions.py +147 -0
  9. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_state.py +6 -6
  10. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/pyproject.toml +6 -3
  11. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/LICENSE +0 -0
  12. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/__init__.py +0 -0
  13. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_feature_usage.py +0 -0
  14. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_loader.py +0 -0
  15. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_models.py +0 -0
  16. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/__init__.py +0 -0
  17. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
  18. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_errors.py +0 -0
  19. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
  20. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
  21. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
  22. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
  23. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_factory.py +0 -0
  24. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_http_handler.py +0 -0
  25. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
  26. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/_workflows/_state_path.py +0 -0
  27. {agent_framework_declarative-1.3.0 → agent_framework_declarative-1.4.1}/agent_framework_declarative/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-framework-declarative
3
- Version: 1.3.0
3
+ Version: 1.4.1
4
4
  Summary: Declarative specification support for Microsoft Agent Framework.
5
5
  Author-email: Microsoft <af-support@microsoft.com>
6
6
  Requires-Python: >=3.10
@@ -9,14 +9,14 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Typing :: Typed
17
16
  License-File: LICENSE
18
- Requires-Dist: agent-framework-core>=1.20.0,<2
17
+ Requires-Dist: agent-framework-core>=1.23.0,<2
19
18
  Requires-Dist: httpx>=0.27,<0.29
19
+ Requires-Dist: httpx2>=2.0.0,<3
20
20
  Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
21
21
  Requires-Dist: pyyaml>=6.0,<7.0
22
22
  Project-URL: homepage, https://aka.ms/agent-framework
@@ -33,7 +33,8 @@ Please install this package via pip:
33
33
  pip install agent-framework-declarative
34
34
  ```
35
35
 
36
- The package currently advertises Python 3.10–3.13 support. Python 3.14 and
36
+ The package currently advertises Python 3.11–3.13 support. Python 3.10
37
+ installation remains permitted, but compatibility is not guaranteed. Python 3.14 and
37
38
  3.15 can install the package, but Power Fx expressions are unavailable because
38
39
  the `powerfx` dependency requires a `pythonnet` build that does not support
39
40
  those Python versions.
@@ -6,7 +6,8 @@ Please install this package via pip:
6
6
  pip install agent-framework-declarative
7
7
  ```
8
8
 
9
- The package currently advertises Python 3.10–3.13 support. Python 3.14 and
9
+ The package currently advertises Python 3.11–3.13 support. Python 3.10
10
+ installation remains permitted, but compatibility is not guaranteed. Python 3.14 and
10
11
  3.15 can install the package, but Power Fx expressions are unavailable because
11
12
  the `powerfx` dependency requires a `pythonnet` build that does not support
12
13
  those Python versions.
@@ -46,6 +46,10 @@ from agent_framework import (
46
46
  from agent_framework._workflows._state import State
47
47
 
48
48
  from ._errors import DeclarativeWorkflowError
49
+ from ._powerfx_functions import (
50
+ _iter_powerfx_expression_indices, # pyright: ignore[reportPrivateUsage]
51
+ whole_call_args,
52
+ )
49
53
  from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
50
54
  from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage]
51
55
 
@@ -68,73 +72,6 @@ logger = logging.getLogger(__name__)
68
72
  _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
69
73
 
70
74
 
71
- def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
72
- """Return the end of an ordinary quoted token or comment, or start if neither."""
73
- quote = formula[start]
74
- if quote in ('"', "'"):
75
- pos = start + 1
76
- while pos < len(formula):
77
- if formula[pos] == quote:
78
- if pos + 1 < len(formula) and formula[pos + 1] == quote:
79
- pos += 2
80
- continue
81
- return pos + 1
82
- pos += 1
83
- return pos
84
- if formula.startswith("//", start):
85
- pos = start + 2
86
- while pos < len(formula) and formula[pos] not in "\r\n":
87
- pos += 1
88
- return pos
89
- if formula.startswith("/*", start):
90
- end = formula.find("*/", start + 2)
91
- return len(formula) if end == -1 else end + 2
92
- return start
93
-
94
-
95
- def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]:
96
- """Yield code positions, excluding literals/comments but including interpolation expressions."""
97
- # None denotes interpolated text; integers track record braces in expression sections.
98
- scopes: list[int | None] = [0]
99
- cursor = 0
100
- while cursor < len(formula):
101
- depth = scopes[-1]
102
- char = formula[cursor]
103
- if depth is None:
104
- if formula.startswith(('""', "{{", "}}"), cursor):
105
- cursor += 2
106
- continue
107
- if char == "{":
108
- scopes.append(0)
109
- elif char == '"':
110
- scopes.pop()
111
- cursor += 1
112
- continue
113
-
114
- if formula.startswith('$"', cursor):
115
- scopes.append(None)
116
- cursor += 2
117
- continue
118
-
119
- token_end = _skip_powerfx_opaque_token(formula, cursor)
120
- if token_end != cursor:
121
- cursor = token_end
122
- continue
123
-
124
- if char == "{":
125
- scopes[-1] = depth + 1
126
- elif char == "}":
127
- if depth > 0:
128
- scopes[-1] = depth - 1
129
- elif len(scopes) > 1:
130
- scopes.pop()
131
- cursor += 1
132
- continue
133
-
134
- yield cursor
135
- cursor += 1
136
-
137
-
138
75
  @dataclass(frozen=True)
139
76
  class DeclarativeEnvConfig:
140
77
  """Configuration that populates the PowerFx ``Env`` symbol for a workflow.
@@ -721,14 +658,14 @@ class DeclarativeWorkflowState:
721
658
 
722
659
  Returns None if the formula is not a custom function call.
723
660
  """
724
- import re
661
+ formula = formula.strip()
725
662
 
726
663
  # Concat/Concatenate - string concatenation
727
664
  # In standard PowerFx, Concatenate is for strings, Concat is for tables.
728
665
  # Copilot Studio uses Concat for strings, so we support both.
729
- match = re.match(r"(?:Concat|Concatenate)\((.+)\)$", formula.strip())
730
- if match:
731
- args_str = match.group(1)
666
+ args_str = whole_call_args(formula, "Concat", "Concatenate")
667
+ # Empty arguments fall through so PowerFx reports the arity error.
668
+ if args_str:
732
669
  # Parse comma-separated arguments (handling nested parentheses)
733
670
  args = self._parse_function_args(args_str)
734
671
  evaluated_args: list[str] = []
@@ -747,24 +684,24 @@ class DeclarativeWorkflowState:
747
684
  return "".join(evaluated_args)
748
685
 
749
686
  # UserMessage(expr) - creates a user message dict
750
- match = re.match(r"UserMessage\((.+)\)$", formula.strip())
751
- if match:
752
- inner_expr = match.group(1).strip()
687
+ inner_args = whole_call_args(formula, "UserMessage")
688
+ if inner_args:
689
+ inner_expr = inner_args.strip()
753
690
  # Evaluate the inner expression
754
691
  text = self.eval(f"={inner_expr}")
755
692
  return {"role": "user", "text": str(text) if text else ""}
756
693
 
757
694
  # AgentMessage(expr) - creates an assistant message dict
758
- match = re.match(r"AgentMessage\((.+)\)$", formula.strip())
759
- if match:
760
- inner_expr = match.group(1).strip()
695
+ inner_args = whole_call_args(formula, "AgentMessage")
696
+ if inner_args:
697
+ inner_expr = inner_args.strip()
761
698
  text = self.eval(f"={inner_expr}")
762
699
  return {"role": "assistant", "text": str(text) if text else ""}
763
700
 
764
701
  # MessageText(expr) - extracts text from the last message
765
- match = re.match(r"MessageText\((.+)\)$", formula.strip())
766
- if match:
767
- inner_expr = match.group(1).strip()
702
+ inner_args = whole_call_args(formula, "MessageText")
703
+ if inner_args:
704
+ inner_expr = inner_args.strip()
768
705
  # Reuse the helper method for consistent text extraction
769
706
  return self._eval_and_replace_message_text(inner_expr)
770
707
 
@@ -53,7 +53,8 @@ class SetValueExecutor(DeclarativeActionExecutor):
53
53
  """Handle the SetValue action."""
54
54
  state = await self._ensure_state_initialized(ctx, trigger)
55
55
 
56
- path = self._action_def.get("path")
56
+ # The builder accepts 'variable' in place of 'path' for SetValue
57
+ path = self._action_def.get("path") or _get_variable_path(self._action_def)
57
58
  value = self._action_def.get("value")
58
59
 
59
60
  if path:
@@ -32,7 +32,7 @@ from collections.abc import Mapping
32
32
  from dataclasses import dataclass, field
33
33
  from typing import Any
34
34
 
35
- import httpx
35
+ import httpx2 as httpx
36
36
  from agent_framework import (
37
37
  Content,
38
38
  Message,
@@ -503,10 +503,10 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
503
503
  )
504
504
  except Exception as exc:
505
505
  try:
506
- from mcp.shared.exceptions import McpError
506
+ from mcp.shared.exceptions import MCPError
507
507
  except ImportError: # pragma: no cover - mcp is a hard dep
508
508
  raise
509
- if isinstance(exc, McpError):
509
+ if isinstance(exc, MCPError):
510
510
  message = str(exc) or type(exc).__name__
511
511
  return MCPToolResult(
512
512
  outputs=[Content.from_text(f"Error: {message}")],
@@ -243,6 +243,27 @@ class BaseToolExecutor(DeclarativeActionExecutor):
243
243
 
244
244
  return None
245
245
 
246
+ def _registered_tool_requires_approval(
247
+ self,
248
+ function_name: str,
249
+ ctx: WorkflowContext[Any, Any],
250
+ ) -> bool:
251
+ """Check whether the registered tool requires approval on every invocation.
252
+
253
+ A tool registered with ``approval_mode="always_require"`` must not run
254
+ without approval, even when the action omits ``requireApproval`` or sets
255
+ it to false.
256
+
257
+ Args:
258
+ function_name: Name of the function
259
+ ctx: Workflow context
260
+
261
+ Returns:
262
+ True if the registered tool requires approval, False otherwise
263
+ """
264
+ tool = self._get_tool(function_name, ctx)
265
+ return getattr(tool, "approval_mode", None) == "always_require"
266
+
246
267
  def _get_output_config(self) -> tuple[str | None, str | None, Any]:
247
268
  """Parse output bindings and the unevaluated autoSend setting.
248
269
 
@@ -433,7 +454,8 @@ class BaseToolExecutor(DeclarativeActionExecutor):
433
454
  ) -> None:
434
455
  """Handle the tool invocation with optional approval flow.
435
456
 
436
- When requireApproval=true:
457
+ When requireApproval=true, or the registered tool has
458
+ approval_mode="always_require":
437
459
  1. Saves invocation state to State (keyed by executor ID)
438
460
  2. Emits ToolApprovalRequest via ctx.request_info()
439
461
  3. Workflow yields (returns without ActionComplete)
@@ -477,10 +499,11 @@ class BaseToolExecutor(DeclarativeActionExecutor):
477
499
  for key, value in arguments_def.items(): # type: ignore[reportUnknownVariableType]
478
500
  arguments[key] = state.eval_if_expression(value)
479
501
 
480
- # Check if approval is required
502
+ # Check if approval is required. The action's requireApproval cannot
503
+ # override a registered tool's approval_mode="always_require".
481
504
  require_approval = self._action_def.get("requireApproval", False)
482
505
 
483
- if require_approval:
506
+ if require_approval or self._registered_tool_requires_approval(function_name, ctx):
484
507
  # Emit approval request - the request payload is the source of
485
508
  # truth for resumed invocation; no side-channel state is written.
486
509
  request_id = str(uuid.uuid4())
@@ -27,6 +27,7 @@ must trust the MCP server they invoke.
27
27
  from __future__ import annotations
28
28
 
29
29
  import asyncio
30
+ import contextlib
30
31
  import hashlib
31
32
  import json
32
33
  import logging
@@ -36,7 +37,7 @@ from contextvars import ContextVar, Token
36
37
  from dataclasses import dataclass, field
37
38
  from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
38
39
 
39
- import httpx
40
+ import httpx2 as httpx
40
41
 
41
42
  if TYPE_CHECKING:
42
43
  from agent_framework import Content
@@ -345,10 +346,10 @@ class DefaultMCPToolHandler:
345
346
  # Be defensive about MCP errors that may bubble up without being
346
347
  # wrapped in ToolExecutionException by custom parsers.
347
348
  try:
348
- from mcp.shared.exceptions import McpError
349
+ from mcp.shared.exceptions import MCPError
349
350
  except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
350
351
  raise
351
- if isinstance(exc, McpError):
352
+ if isinstance(exc, MCPError):
352
353
  message = str(exc) or type(exc).__name__
353
354
  return MCPToolResult(
354
355
  outputs=[Content.from_text(f"Error: {message}")],
@@ -369,7 +370,7 @@ class DefaultMCPToolHandler:
369
370
  async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
370
371
  """Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
371
372
 
372
- Pages through ``session.list_tools()`` (mirroring the pagination loop
373
+ Pages through the normalized MCP connection (mirroring the pagination loop
373
374
  in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
374
375
  full catalog as a single ``TextContent`` containing JSON of shape
375
376
  ``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
@@ -383,8 +384,16 @@ class DefaultMCPToolHandler:
383
384
  """
384
385
  from agent_framework import Content
385
386
 
386
- session = getattr(entry.tool, "session", None)
387
- if session is None:
387
+ require_connection: Any = getattr(entry.tool, "_require_connection", None)
388
+ connection: Any | None = None
389
+ session: Any | None = None
390
+ if callable(require_connection):
391
+ with contextlib.suppress(RuntimeError):
392
+ connection = require_connection()
393
+ else:
394
+ session = getattr(entry.tool, "session", None)
395
+
396
+ if connection is None and session is None:
388
397
  message = "MCP session is not connected; cannot list tools."
389
398
  return MCPToolResult(
390
399
  outputs=[Content.from_text(f"Error: {message}")],
@@ -398,9 +407,14 @@ class DefaultMCPToolHandler:
398
407
  collected: list[Any] = []
399
408
  params: mcp_types.PaginatedRequestParams | None = None
400
409
  while True:
401
- tool_list = await session.list_tools(params=params)
410
+ if connection is not None:
411
+ tool_list = await connection.list_tools_page(params)
412
+ elif session is not None:
413
+ tool_list = await session.list_tools(params=params)
414
+ else: # pragma: no cover - guarded before entering the loop
415
+ raise RuntimeError("MCP connection became unavailable while listing tools.")
402
416
  collected.extend(tool_list.tools)
403
- next_cursor = getattr(tool_list, "nextCursor", None)
417
+ next_cursor = getattr(tool_list, "next_cursor", getattr(tool_list, "nextCursor", None))
404
418
  if not next_cursor:
405
419
  break
406
420
  params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
@@ -410,8 +424,8 @@ class DefaultMCPToolHandler:
410
424
  {
411
425
  "name": tool.name,
412
426
  "description": tool.description,
413
- "inputSchema": tool.inputSchema,
414
- "outputSchema": tool.outputSchema,
427
+ "inputSchema": getattr(tool, "input_schema", getattr(tool, "inputSchema", None)),
428
+ "outputSchema": getattr(tool, "output_schema", getattr(tool, "outputSchema", None)),
415
429
  }
416
430
  for tool in collected
417
431
  ],
@@ -12,9 +12,156 @@ or registered with the PowerFx engine when it is available.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ from collections.abc import Iterator
15
16
  from typing import Any, cast
16
17
 
17
18
 
19
+ def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
20
+ """Return the end of an ordinary quoted token or comment, or start if neither."""
21
+ quote = formula[start]
22
+ if quote in ('"', "'"):
23
+ pos = start + 1
24
+ while pos < len(formula):
25
+ if formula[pos] == quote:
26
+ if pos + 1 < len(formula) and formula[pos + 1] == quote:
27
+ pos += 2
28
+ continue
29
+ return pos + 1
30
+ pos += 1
31
+ return pos
32
+ if formula.startswith("//", start):
33
+ pos = start + 2
34
+ while pos < len(formula) and formula[pos] not in "\r\n":
35
+ pos += 1
36
+ return pos
37
+ if formula.startswith("/*", start):
38
+ end = formula.find("*/", start + 2)
39
+ # An unterminated /* is not a complete comment: leave it as code so a
40
+ # trailing one is not mistaken for trivia following a complete call.
41
+ return start if end == -1 else end + 2
42
+ return start
43
+
44
+
45
+ def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]:
46
+ """Yield code positions, excluding literals/comments but including interpolation expressions."""
47
+ # None denotes interpolated text; integers track record braces in expression sections.
48
+ scopes: list[int | None] = [0]
49
+ cursor = 0
50
+ while cursor < len(formula):
51
+ depth = scopes[-1]
52
+ char = formula[cursor]
53
+ if depth is None:
54
+ if formula.startswith(('""', "{{", "}}"), cursor):
55
+ cursor += 2
56
+ continue
57
+ if char == "{":
58
+ scopes.append(0)
59
+ elif char == '"':
60
+ scopes.pop()
61
+ cursor += 1
62
+ continue
63
+
64
+ if formula.startswith('$"', cursor):
65
+ scopes.append(None)
66
+ cursor += 2
67
+ continue
68
+
69
+ token_end = _skip_powerfx_opaque_token(formula, cursor)
70
+ if token_end != cursor:
71
+ cursor = token_end
72
+ continue
73
+
74
+ if char == "{":
75
+ scopes[-1] = depth + 1
76
+ elif char == "}":
77
+ if depth > 0:
78
+ scopes[-1] = depth - 1
79
+ elif len(scopes) > 1:
80
+ scopes.pop()
81
+ cursor += 1
82
+ continue
83
+
84
+ yield cursor
85
+ cursor += 1
86
+
87
+
88
+ def _after_trailing_trivia(formula: str, start: int) -> int:
89
+ """Return the index after trailing whitespace and complete comments.
90
+
91
+ Unlike the expression walker, this never skips a string literal: a quoted
92
+ token after the call's own closing paren is trailing code, not trivia, so it
93
+ stops the scan and leaves the formula to PowerFx.
94
+ """
95
+ index = start
96
+ while index < len(formula):
97
+ if formula[index].isspace():
98
+ index += 1
99
+ continue
100
+ if formula.startswith(("//", "/*"), index):
101
+ token_end = _skip_powerfx_opaque_token(formula, index)
102
+ if token_end == index:
103
+ # Unterminated /* is not a complete comment, so real code follows.
104
+ break
105
+ index = token_end
106
+ continue
107
+ break
108
+ return index
109
+
110
+
111
+ def whole_call_args(formula: str, *names: str) -> str | None:
112
+ """Return the argument text when ``formula`` is exactly one ``Name(...)`` call.
113
+
114
+ A custom-function handler must not fire for a formula that merely *starts*
115
+ with its name: the closing parenthesis has to be the call's own and nothing
116
+ but trivia may follow it. Otherwise a trailing operator is swallowed into the
117
+ argument list and the formula is evaluated as something the author never
118
+ wrote, instead of being left to PowerFx.
119
+
120
+ Parentheses inside string literals and comments are data, not delimiters, so
121
+ the scan walks the formula with the shared PowerFx expression walker: it
122
+ skips those tokens whole and counts only parentheses that are real code,
123
+ including those inside an interpolated string's ``{...}`` expression sections.
124
+
125
+ Args:
126
+ formula: The stripped formula to inspect.
127
+ *names: Candidate function names, matched case-sensitively.
128
+
129
+ Returns:
130
+ The text between the call's parentheses - an empty string for an
131
+ argument-less call - or None when ``formula`` is not a single complete
132
+ call to one of ``names``. Callers that require an argument check the
133
+ result for emptiness so the call still falls through to PowerFx, which
134
+ reports the arity error.
135
+ """
136
+ for name in names:
137
+ prefix = f"{name}("
138
+ if not formula.startswith(prefix):
139
+ continue
140
+ positions = _iter_powerfx_expression_indices(formula)
141
+ depth = 0
142
+ close_index = -1
143
+ for index in positions:
144
+ char = formula[index]
145
+ if char == "(":
146
+ depth += 1
147
+ elif char == ")":
148
+ depth -= 1
149
+ if depth == 0:
150
+ close_index = index
151
+ break
152
+ if close_index == -1:
153
+ # Unbalanced: the call never closes, so it is not a whole call.
154
+ return None
155
+ # Only whitespace and complete comments may follow the call's own closing
156
+ # paren. The suffix scan deliberately does not reuse the literal-skipping
157
+ # walker: a trailing string literal (or any other code) there means the
158
+ # formula is a larger expression the caller must not claim.
159
+ if _after_trailing_trivia(formula, close_index + 1) != len(formula):
160
+ return None
161
+ return formula[len(prefix) : close_index]
162
+ return None
163
+
164
+
18
165
  def message_text(messages: Any) -> str:
19
166
  """Extract text content from a message or list of messages.
20
167
 
@@ -441,7 +441,7 @@ class WorkflowState:
441
441
  Returns:
442
442
  The evaluated result
443
443
  """
444
- from ._powerfx_functions import CUSTOM_FUNCTIONS
444
+ from ._powerfx_functions import CUSTOM_FUNCTIONS, whole_call_args
445
445
 
446
446
  formula = formula.strip()
447
447
 
@@ -452,15 +452,15 @@ class WorkflowState:
452
452
  return not bool(result)
453
453
 
454
454
  # Handle Not() function
455
- if formula.startswith("Not(") and formula.endswith(")"):
456
- inner = formula[4:-1].strip()
457
- result = self._eval_simple(inner)
455
+ not_args = whole_call_args(formula, "Not")
456
+ if not_args is not None:
457
+ result = self._eval_simple(not_args.strip())
458
458
  return not bool(result)
459
459
 
460
460
  # Handle function calls
461
461
  for func_name, func in CUSTOM_FUNCTIONS.items():
462
- if formula.startswith(f"{func_name}(") and formula.endswith(")"):
463
- args_str = formula[len(func_name) + 1 : -1]
462
+ args_str = whole_call_args(formula, func_name)
463
+ if args_str is not None:
464
464
  # Simple argument parsing (doesn't handle nested calls well)
465
465
  args = self._parse_function_args(args_str)
466
466
  evaluated_args = [self._eval_simple(arg) if isinstance(arg, str) else arg for arg in args]
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
4
4
  authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
- version = "1.3.0"
7
+ version = "1.4.1"
8
8
  license-files = ["LICENSE"]
9
9
  urls.homepage = "https://aka.ms/agent-framework"
10
10
  urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -15,15 +15,15 @@ classifiers = [
15
15
  "Development Status :: 5 - Production/Stable",
16
16
  "Intended Audience :: Developers",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.10",
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: 3.13",
22
21
  "Typing :: Typed",
23
22
  ]
24
23
  dependencies = [
25
- "agent-framework-core>=1.20.0,<2",
24
+ "agent-framework-core>=1.23.0,<2",
26
25
  "httpx>=0.27,<0.29",
26
+ "httpx2>=2.0.0,<3",
27
27
  "powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
28
28
  "pyyaml>=6.0,<7.0",
29
29
  ]
@@ -31,6 +31,9 @@ dependencies = [
31
31
  dev = [
32
32
  "types-PyYaml==6.0.12.20260906"
33
33
  ]
34
+ test = [
35
+ "tenacity>=9.1.4,<9.2.0",
36
+ ]
34
37
 
35
38
  [tool.uv]
36
39
  prerelease = "if-necessary"