Skip to content

Python: Bound declarative custom-function matches to one whole call - #9176

Open
Atharva Vichare (atty57) wants to merge 5 commits into
microsoft:mainfrom
atty57:fix/declarative-custom-function-call-extent
Open

Atharva Vichare (atty57) wants to merge 5 commits into
microsoft:mainfrom
atty57:fix/declarative-custom-function-call-extent

Conversation

@atty57

Copy link
Copy Markdown
Contributor

Motivation & Context

The declarative expression layer matches its Copilot Studio dialect with patterns anchored to the end of the whole formula, and neither checks that the closing parenthesis belongs to the call being matched:

# _declarative_base.py, DeclarativeWorkflowState._eval_custom_function
re.match(r"(?:Concat|Concatenate)\((.+)\)$", formula.strip())   # + UserMessage, AgentMessage, MessageText

# _state.py, WorkflowState._eval_simple
formula.startswith(f"{func_name}(") and formula.endswith(")")    # + the Not() special case

So any formula that begins with one of these names and ends with ) is captured whole, and everything after the call's own closing paren is swallowed into the argument list. _eval_custom_function runs before PowerFx and eval() returns on any non-None result, so the mis-parse wins and the formula never reaches the engine that would have handled it correctly.

Two symptoms, depending on what the swallowed tail looks like:

Formula Expected Before
Concat("a") & Lower("B") ab a") & Lower("B
Concatenate("x") & Upper("y") xY x") & Upper("y
MessageText("hi") & Upper("there") hiTHERE ValueError: Power Fx failed compilation: Error 4 - 5
Concat("a", "b") (whole call) ab ab ✔

For Concat/Concatenate the tail still begins and ends with ", so the literal branch strips the outer quotes and emits the formula's own source as data — silently wrong output, no error. For MessageText the tail is re-evaluated as a PowerFx fragment (="hi") & Upper("there"), raising a spurious compilation error for a formula that is valid PowerFx.

Description & Review Guide

What are the major changes?

  • Adds whole_call_args(formula, *names) to _powerfx_functions.py. It scans for the parenthesis that closes the call, tracking nesting depth and string literals (so a ) inside a quoted literal is data, not a terminator), and accepts the match only when that parenthesis is the formula's last character. Argument-less calls return None so PowerFx keeps reporting the arity error, matching the old (.+) behaviour of requiring at least one argument.
  • DeclarativeWorkflowState._eval_custom_function uses it for all five names, replacing the four regexes and the now-unused local import re.
  • WorkflowState._eval_simple uses it for the CUSTOM_FUNCTIONS dispatch loop and for the Not() special case above it, which had the same flaw. This path is reachable whenever PowerFx itself fails first and execution falls back: WorkflowState().eval('=Concat("a") & Lower("B")') returned the same corrupted a") & Lower("B'.
  • 17 tests: unit coverage for the helper (nesting, quoted parens, empty and unbalanced arguments, case sensitivity, name-not-at-start) and behavioural regressions on both evaluators. All of them are engine-free, so they run whether or not dotnet/powerfx is present.

What is the impact of these changes?

A formula that is a single complete call is unaffected — that is the Concat("a", "b") control row above, and the existing suite covers these paths (test_powerfx_yaml_compatibility.py, test_declarative_state_path_safety.py).

One deliberate behaviour change worth a reviewer's attention: Concat(...) mid-expression now falls through to PowerFx rather than being mis-parsed, and standard PowerFx Concat is the table aggregator, so =Concat("a") & Lower("B") raises Invalid number of arguments: received 1, expected 2-3 instead of silently returning a") & Lower("B. A loud, accurate error replaces corrupted data, but it is not ab. The Copilot Studio string alias only ever applied when Concat( started the formula, which is why Concat resolves to two different functions depending on its position — =Lower("B") & Concat("a") already reached real PowerFx and failed this way before this change. Making the alias apply uniformly inside a larger expression needs expression rewriting rather than extent matching, so I have left it out of this PR; Concatenate and & both compose correctly now. Happy to open a follow-up issue if you would like that tracked.

What do you want reviewers to focus on?

Whether falling through to PowerFx is the right call for Concat mid-expression (above), and whether whole_call_args belongs in _powerfx_functions.py — it is the one module both evaluators already import, but it is a parsing helper rather than a PowerFx function, and it is deliberately not registered in CUSTOM_FUNCTIONS.

Two notes on adjacent issues, neither of which this PR touches:

Related Issue

Fixes #9173

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.
Verification
uv run poe --directory packages/declarative test      1365 passed (1347 before, +18)
uv run poe --directory packages/declarative syntax    ruff format + check: All checks passed
uv run poe --directory packages/declarative pyright   0 errors, 0 warnings, 0 informations
uv run poe --directory packages/declarative mypy      12 errors in 6 files, identical on an
                                                      unmodified tree - all pre-existing

The two behavioural regressions were confirmed to fail against the unmodified source before the fix was applied, including with the exact corrupted value:

>       assert state._eval_simple(formula) == formula
E       assert 'a") & Lower("B' == 'Concat("a") & Lower("B")'

Verified end-to-end through WorkflowFactory with a live engine (dotnet 10.0.303, powerfx 0.0.34), not only the no-engine path.

The declarative expression layer matched its Copilot Studio dialect with
end-anchored patterns: `re.match(r"Name\((.+)\)$", formula)` in
`DeclarativeWorkflowState._eval_custom_function`, and
`formula.startswith(f"{name}(") and formula.endswith(")")` in
`WorkflowState._eval_simple`. Neither checks that the closing parenthesis
is the call's own, so any formula beginning with one of these names and
ending in `)` had the text after the call absorbed into its arguments.

`Concat("a") & Lower("B")` returned `a") & Lower("B` - the formula's own
source emitted as data, with no error - because the swallowed tail still
began and ended with a quote and took the string-literal branch.
`MessageText(x) & Upper(y)` instead re-evaluated a malformed fragment and
raised a spurious PowerFx compilation error for a valid formula. In both
cases the mis-parse won before PowerFx, which would have evaluated the
formula correctly, ever saw it.

Add `whole_call_args`, which scans for the parenthesis that closes the
call while tracking nesting and string literals, and accepts the match
only when that parenthesis ends the formula. Argument-less calls return
None so PowerFx keeps reporting the arity error. Both evaluators now use
it, including the `Not()` special case in `_eval_simple`, which had the
same flaw.

Formulas that are a single complete call are unaffected.

Fixes microsoft#9173

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Oct 7, 2026
@eavanvalkenburg

Copy link
Copy Markdown
Member

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 67f01e4c823e
Model: gpt-5.6-sol

Overview

The PR replaces permissive prefix/suffix matching with a shared whole-call scanner, and its nesting, quoted-parenthesis, and trailing-expression tests establish useful guardrails. However, the scanner does not honor PowerFx comments as opaque syntax, and its empty-argument sentinel changes the public fallback evaluator's existing zero-argument behavior. Both gaps can reject valid formulas or return an unevaluated truthy string in place of the prior function result.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
2 verified findings remained after source verification (2 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py

elif char == string_char and in_string:
in_string = False
string_char = None
elif char == "(" and not in_string:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Atharva Vichare (@atty57) whole_call_args() counts parentheses inside Power Fx comments, so a valid call such as UserMessage("hi" /* ) */) is rejected as a whole custom-function call and falls through to Power Fx, which cannot evaluate that custom function. The scanner needs to skip Power Fx line/block comments (and other opaque tokens) while tracking parentheses, with a commented-argument regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, thanks , whole_call_args('UserMessage("hi" /* ) */)', 'UserMessage') comes back None on this branch, and Upper("x") // trailing ( breaks the same way. The scan reads comment text as code, so a ) inside a comment closes the call early, and a ( in a trailing comment leaves the depth count unbalanced.
Scanner for this already exists _skip_powerfx_opaque_token in _declarative_base.py already handles both comment forms plus "" escapes inside literals. I'll move it over to _powerfx_functions.py, next to whole_call_args, and call it from the boundary scan. _declarative_base already imports from _powerfx_functions, so the helper has to live on that side to keep the imports one-directional.
Regressions going in for a block comment containing ), a trailing line comment containing (, and an escaped quote around a paren on both evaluators.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Atharva Vichare (@atty57) The scanner still accepts an unterminated trailing block comment as trivia: =Concat("a") /* unterminated is claimed by the custom-function path and returns "a" instead of falling through to Power Fx syntax handling. A trailing /* ... */ should only be skipped when the closing */ exists, with a complete-call plus unterminated-comment regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fixed now

Review follow-up on two gaps in `whole_call_args`.

A parenthesis inside a PowerFx comment was counted as a call delimiter, so
a whole call such as `UserMessage("hi" /* ) */)` reached depth zero at the
commented `)` and was rejected, and a trailing comment disqualified the
match because the closing parenthesis was no longer the last character.
`_declarative_base` already had `_skip_powerfx_opaque_token`, which treats
quoted tokens - including `""` escapes - and both comment forms as opaque;
move it next to `whole_call_args` and scan with it, so the two evaluators
and the expression-index walker share one notion of what is code. The
import direction stays one-way: `_declarative_base` already depends on
`_powerfx_functions`.

Returning None for a matched argument-less call also made it
indistinguishable from no call at all. `WorkflowState.eval("=Or()")`
invoked `or_func()` and returned False before; it fell through and echoed
the formula, and `"Or()"` is truthy, which inverts a condition. Return the
empty string for a matched empty call and keep None for no match.
`_eval_simple` keeps dispatching on `is not None`, and the declarative
handlers that require an argument now check for emptiness, so they still
fall through to PowerFx for the arity error.
_skip_trailing_trivia skipped an unterminated /* to end of the formula,
so a complete call followed by an unclosed comment (e.g. =Concat("a") /*
unterminated) was claimed by the custom-function path instead of falling
through to PowerFx. A trailing /* is now only skipped when its closing */
exists; otherwise the call is treated as part of a larger expression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MTr4hwW95P2Ar3m6957Dqm

This branch was successfully deployed

1 active deployment
github-app-auth — 0ee0c31b Deployed Oct 9, 2026 by atty57 via add_label #24902
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: Python: [Bug]: Declarative custom-function regexes swallow the formula tail, silently emitting formula source or failing compilation

4 participants