Repository navigation
Python: Bound declarative custom-function matches to one whole call - #9176
Atharva Vichare (atty57) wants to merge 5 commits into
Conversation
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
|
/review |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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:
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_functionruns before PowerFx andeval()returns on any non-Noneresult, 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:
Concat("a") & Lower("B")aba") & Lower("BConcatenate("x") & Upper("y")xYx") & Upper("yMessageText("hi") & Upper("there")hiTHEREValueError: Power Fx failed compilation: Error 4 - 5Concat("a", "b")(whole call)abab✔For
Concat/Concatenatethe 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. ForMessageTextthe 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?
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 returnNoneso PowerFx keeps reporting the arity error, matching the old(.+)behaviour of requiring at least one argument.DeclarativeWorkflowState._eval_custom_functionuses it for all five names, replacing the four regexes and the now-unused localimport re.WorkflowState._eval_simpleuses it for theCUSTOM_FUNCTIONSdispatch loop and for theNot()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 corrupteda") & Lower("B'.dotnet/powerfxis 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 PowerFxConcatis the table aggregator, so=Concat("a") & Lower("B")raisesInvalid number of arguments: received 1, expected 2-3instead of silently returninga") & Lower("B. A loud, accurate error replaces corrupted data, but it is notab. The Copilot Studio string alias only ever applied whenConcat(started the formula, which is whyConcatresolves 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;Concatenateand&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
Concatmid-expression (above), and whetherwhole_call_argsbelongs 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 inCUSTOM_FUNCTIONS.Two notes on adjacent issues, neither of which this PR touches:
Concat("a", "b")control row evaluates correctly before and after. The two compose: once the extent is right, Python: Declarative concatenation treats quoted expressions as literal text #9072's fix governs the arguments. That author mentioned they have a fix in progress, so I have kept this change clear of the argument-parsing branch to avoid a conflict.UserMessage/AgentMessagereturn different shapes from the two state classes) is whyMessageText(...)returns''for a message list in some paths. That is independent of this change — it reproduces identically on the untouched whole-call and nested paths.Related Issue
Fixes #9173
Contribution Checklist
Verification
The two behavioural regressions were confirmed to fail against the unmodified source before the fix was applied, including with the exact corrupted value:
Verified end-to-end through
WorkflowFactorywith a live engine (dotnet 10.0.303, powerfx 0.0.34), not only the no-engine path.