Skip to content

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

Description

Description

DeclarativeWorkflowState._eval_custom_function matches its custom-function dialect with regexes anchored to the end of the whole formula:

# python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py
match = re.match(r"(?:Concat|Concatenate)\((.+)\)$", formula.strip())   # L729
match = re.match(r"UserMessage\((.+)\)$", formula.strip())              # L750
match = re.match(r"AgentMessage\((.+)\)$", formula.strip())             # L758
match = re.match(r"MessageText\((.+)\)$", formula.strip())              # L765

(.+) is greedy and \)$ only requires the formula to end with some ) — not that the paren closes the call being matched. So any formula that starts 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() consults _eval_custom_function before PowerFx and 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 follow, depending on what the swallowed tail looks like:

Formula Expected Actual
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 : Unexpected characters.
Concat("a", "b") (control, no trailing operator) ab ab ✔

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

A related consequence: because the alias only applies when Concat( is at position 0, Concat resolves to two different functions depending on where it sits in the formula. =Concat("a") is intercepted as the Copilot Studio string alias, while =Lower("B") & Concat("a") reaches real PowerFx and fails with Invalid number of arguments: received 1, expected 2-3 — the table-aggregator signature.

What I expected: a custom-function regex to match only when the call spans the entire formula, so that a formula merely beginning with Concat(/Concatenate(/UserMessage(/AgentMessage(/MessageText( falls through to PowerFx instead of being mis-parsed.

What happens instead: the tail after the call's closing paren is absorbed into the arguments, yielding either formula source text as output or a compilation error.

Steps to reproduce

  1. Install agent-framework-declarative with a working PowerFx engine (dotnet + powerfx).
  2. Define a declarative workflow whose SendActivity.activity.text is a formula that starts with one of the five custom-function names and continues with any operator, e.g. =Concat(Local.First, " ") & Upper(Local.Last).
  3. Run the workflow and inspect the output.

Code Sample

import asyncio

from agent_framework_declarative import WorkflowFactory

CASES = [
    ('=Concat("a") & Lower("B")', "ab"),
    ('=Concatenate("x") & Upper("y")', "xY"),
    ('=MessageText("hi") & Upper("there")', "hiTHERE"),
    ('=Concat("a", "b")', "ab"),  # control: no trailing operator, works
]


async def main():
    for formula, expected in CASES:
        workflow = WorkflowFactory().create_workflow_from_definition({
            "name": "concat_tail_repro",
            "actions": [{"kind": "SendActivity", "id": "show", "activity": {"text": formula}}],
        })
        try:
            actual = (await workflow.run({})).get_outputs()
        except Exception as e:
            actual = f"{type(e).__name__}: {e}"
        print(f"{formula}\n    expected: {expected!r}\n    actual:   {actual}\n")


asyncio.run(main())

Output:

=Concat("a") & Lower("B")
    expected: 'ab'
    actual:   ['a") & Lower("B']
=Concatenate("x") & Upper("y")
    expected: 'xY'
    actual:   ['x") & Upper("y']
=MessageText("hi") & Upper("there")
    expected: 'hiTHERE'
    actual:   ValueError: Power Fx failed compilation: Error 4 - 5 : Unexpected characters. Characters are used in the formula in an unexpected way.
=Concat("a", "b")
    expected: 'ab'
    actual:   ['ab']

Error Messages / Stack Traces

The Concat/Concatenate cases raise nothing — the workflow emits incorrect text. The MessageText case raises:

ValueError: Power Fx failed compilation: Error 4 - 5 : Unexpected characters. Characters are used in the formula in an unexpected way.

Package Versions

Source checkout 40763bed1a5e94a1698ba9ebf009e5622eb099aa: agent-framework-core 1.20.0, agent-framework-declarative 1.2.0, powerfx 0.0.34, dotnet 10.0.303.

Python Version

Python 3.13.2 on Windows x64.

Additional Context

  • Distinct from Python: Declarative concatenation treats quoted expressions as literal text #9072, which concerns the argument literal heuristic (arg.startswith('"') and arg.endswith('"')) inside an otherwise correctly-delimited call. This report is about the outer regex selecting the wrong extent of the formula in the first place; the control row Concat("a", "b") evaluates correctly, so argument handling is not what fails here. The two interact: once the extent is fixed, Python: Declarative concatenation treats quoted expressions as literal text #9072's fix governs the arguments; while the extent is wrong, a Python: Declarative concatenation treats quoted expressions as literal text #9072 fix would change these cases from literal garbage to evaluating a malformed fragment rather than making them correct.
  • A matching-paren scan (or requiring the capture to be balanced before accepting the match) would confine each handler to formulas that are entirely one custom call, which is what the surrounding _preprocess_custom_functions path already assumes for the nested case (Upper(MessageText(...)) works today).
  • Separately, _powerfx_functions.concat_text implements the standard PowerFx table Concat(table, expression, separator) but is registered nowhere — CUSTOM_FUNCTIONS["Concat"] points at the varargs string concat_strings, and concat_text is referenced only from tests. Worth deciding whether the table form should be reachable at all, given the positional split described above.
  • No model or network access is needed for this reproduction.

Activity

  1. added
    .NETUsage: [Issues, PRs], Target: .Net
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 7, 2026
  2. added
    needs-maintainer-triageUsage: [Issues], Target: all issues that the automated triage flow failed to process
    on Oct 7, 2026
  3. changed the title [-]Python: [Bug]: Declarative custom-function regexes swallow the formula tail, silently emitting formula source or failing compilation[/-] [+].NET: Python: [Bug]: Declarative custom-function regexes swallow the formula tail, silently emitting formula source or failing compilation[/+] on Oct 7, 2026
  4. he-yufeng commented on Oct 7, 2026

    @he-yufeng
    Contributor

    Verified on current main (c24ef0a, declarative 1.2.0). The extent bug reproduces exactly as analyzed:

    state.eval('=Concat("a") & Lower("B")')   -> 'a") & Lower("B'   (formula source emitted as data)
    state.eval('=Concat("a", "b")')           -> 'ab'               (control, fine)
    DeclarativeWorkflowState._eval_custom_function(state, 'Concat("a") & Lower("B")')
                                              -> 'a") & Lower("B'   (the mis-capture itself)
    

    The greedy (.+)\)$ capture is the whole story: anything after the call's own closing paren is absorbed into the argument list, and since eval() consults _eval_custom_function before PowerFx, the mis-parse always wins.

    I'd like to take this. Proposed fix: replace the four end-anchored regexes with one shared matcher that finds the call's own closing paren via a balanced scan (strings and nested parens respected) and accepts the match only when that paren is the last non-space character of the formula. Formulas that merely begin with Concat( / MessageText( / etc. then fall through to PowerFx, which both fixes the swallowed tail and resolves the positional duality you flagged: mid-formula Concat keeps its engine meaning. Regression coverage would be your four cases plus Lower("B") & Concat("a") and a string literal containing ").

    One scoping question: your third bullet (the unregistered table-form concat_text) reads as a separate design decision. I'd leave it out of this fix unless you'd rather settle it at the same time.

  5. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    needs-maintainer-triageUsage: [Issues], Target: all issues that the automated triage flow failed to process
    on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .NetpythonUsage: [Issues, PRs], Target: Python

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions