Skip to content

Python: fix(declarative): read every message shape in MessageText - #9101

Open
Yufeng He (he-yufeng) wants to merge 8 commits into
microsoft:mainfrom
he-yufeng:fix/message-text-shape-tolerance
Open

Yufeng He (he-yufeng) wants to merge 8 commits into
microsoft:mainfrom
he-yufeng:fix/message-text-shape-tolerance

Conversation

@he-yufeng

@he-yufeng Yufeng He (he-yufeng) commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Motivation & Context

#9041: the same declarative formula produces two different message shapes depending on which state class evaluates it. WorkflowState's fallback producers emit {"role", "content"} while DeclarativeWorkflowState's custom evaluators emit {"role", "text"}, and each MessageText implementation read only its own side's key, so a message that crossed eval paths silently extracted "". The .NET record shape (a structured content list) fared worse: message_text stringified the content table into the output.

Both producer shapes are pinned by existing tests on each side, so picking one winning key is a behavior-breaking call I left to maintainers in the issue. This PR fixes the harmful half that needs no such decision: readers should never lose a message that crosses paths.

Description & Review Guide

  • What are the major changes? _powerfx_functions.py gains _extract_message_text, a single-message extractor that accepts the flat content shape, the flat text shape, and structured content/contents item lists (lowercase and PascalCase keys). message_text routes its dict, list, and object branches through it, and _declarative_base._eval_and_replace_message_text delegates to it instead of its own text/contents-only logic.
  • What is the impact of these changes? No producer shape changes and no changes for messages that already extracted correctly. Cross-path messages now extract their text instead of returning "" or a stringified table. Lists of message objects that expose only a text attribute (previously skipped) now contribute.
  • What do you want reviewers to focus on? The extraction precedence in _extract_message_text (flat content str, then flat text str, then structured lists), and that _eval_and_replace_message_text keeps its existing list-only contract.

Related Issue

Refs #9041. This PR closes the silent-extraction harm; the producer-convergence question in the issue stays open for a maintainer decision, so this PR intentionally does not use a closing keyword.

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: implementation, tests, and this PR description.

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 links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

WorkflowState's fallback producers emit {"role", "content"} while
DeclarativeWorkflowState's custom evaluators emit {"role", "text"}, and
each MessageText implementation read only its own side's key, so a
message crossing eval paths silently extracted "". The .NET record
shape (structured content list) got stringified into the output.

Both readers now share _extract_message_text, which accepts the flat
content shape, the flat text shape, and structured content/contents
lists (lowercase and PascalCase item keys). Producer shapes are
unchanged; the convergence question in microsoft#9041 stays with maintainers.

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 review overview

🟡 Changes recommended

PascalCase top-level content remains unsupported, and non-text .NET content values are incorrectly extracted as text.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Unifies Python declarative message-text extraction across flat and structured message shapes.

Changes:

  • Adds a shared message-text extractor.
  • Reuses it across both declarative evaluation paths.
  • Adds regression tests for supported message shapes.
File Description
_powerfx_functions.py Implements shared extraction logic.
_declarative_base.py Delegates extraction to the shared helper.
test_powerfx_functions.py Tests additional message shapes.
test_powerfx_yaml_compatibility.py Tests state-level extraction compatibility.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Iterating the Any-typed parameter left the loop variable and the
getattr argument Unknown under strict pyright; bind the list as
list[Any] like the dict casts below it.
@eavanvalkenburg

Copy link
Copy Markdown
Member

Yufeng He (@he-yufeng) The extractor still reads Value from every .NET content record without checking Type. Explicitly typed non-text records such as image URLs or files can therefore be emitted as message text. Please skip records whose declared type is non-text and add a mixed-content regression covering text plus image/file records.

…geText

Explicitly typed image_url/file records carry a payload reference in
value, not displayable text; emitting it could leak the reference into
evaluated expressions. Records with no type declaration keep the
existing key probe, and the PascalCase Text record stays accepted.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Done in af683cb: dict items in a structured content list now check the declared type/Type first, and any non-text record (image_url, file, ...) is skipped instead of having its value emitted as message text. Records with no type declaration keep the existing key probe, so the flat producer shapes and the PascalCase Text record are unaffected. Two regressions added: a mixed text + image_url + file list extracting only the text, and an untyped-payload shape staying empty. Suite green on 3.13 (102 passed in test_powerfx_functions.py, powerfx gated off 3.14 locally), ruff and pyright clean.

…MessageText

System.Text.Json keeps capitalized property names on a serialized record, so a cross-runtime message like {"Role": "user", "Content": [...]} still extracted as empty: the dict branch only looked at lowercase content/contents/text while item-level keys already tolerated both casings. Probe Content/Contents/Text alongside their lowercase twins, lowercase winning when both are present.
if isinstance(item_dict.get(key), str):
parts.append(item_dict[key])
break
elif (item_text := getattr(item, "text", None)) is not None:

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.

Yufeng He (@he-yufeng) The dict path now skips typed non-text content, but the object path still appends any .text attribute without checking item.type. A Content.from_text_reasoning(...) object therefore leaks reasoning text through MessageText, even though core Message.text includes only type == "text". Please apply the same type gate to object items and add a reasoning-content regression.

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.

Yufeng He (@he-yufeng) The current fix gates objects only inside structured content lists. Direct supported shapes such as {"content": Content.from_text_reasoning(...)}, an object whose .content is reasoning content, or a direct typed object still return .text without checking type == "text", so reasoning text can still leak through MessageText. Please apply the same gate to those direct object paths and add regressions.

…eText

The dict path skipped typed non-text content, but the object path still
appended any .text attribute, so a Content.from_text_reasoning object
leaked reasoning text through MessageText while core Message.text keeps
only type == "text". Apply the same type gate to object items and add
a reasoning-content regression test.
…e-tolerance

# Conflicts:
#	python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py
#	python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py

This branch was successfully deployed

1 active deployment
github-app-auth — 72e5b311 Deployed Oct 16, 2026 by he-yufeng via add_label #25600
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.

3 participants