From fecea0b2b868c6c196dd39df4de36c4d00090f86 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:08:35 +0800 Subject: [PATCH 1/5] Python: fix(declarative): read every message shape in MessageText 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 #9041 stays with maintainers. --- .../_workflows/_declarative_base.py | 34 +----- .../_workflows/_powerfx_functions.py | 107 ++++++++++++------ .../tests/test_powerfx_functions.py | 37 ++++++ .../tests/test_powerfx_yaml_compatibility.py | 21 ++++ 4 files changed, 136 insertions(+), 63 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index 42aec8c49bb..3ec2ae270c7 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -46,6 +46,7 @@ from agent_framework._workflows._state import State from ._errors import DeclarativeWorkflowError +from ._powerfx_functions import _extract_message_text # pyright: ignore[reportPrivateUsage] from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage] from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage] @@ -854,34 +855,11 @@ def _eval_and_replace_message_text(self, inner_expr: str) -> str: messages: Any = self.eval(f"={inner_expr}") if isinstance(messages, list) and messages: message_list = cast(list[Any], messages) - last_msg: Any = message_list[-1] - if isinstance(last_msg, dict): - last_msg_dict = cast(dict[str, Any], last_msg) - # Try "text" key first (simple dict format) - if "text" in last_msg_dict: - return str(last_msg_dict["text"]) - # Try extracting from "contents" (Message dict format) - # Message.text concatenates text from all TextContent items - contents_obj = last_msg_dict.get("contents", []) - if isinstance(contents_obj, list): - contents = cast(list[Any], contents_obj) - text_parts: list[str] = [] - for content in contents: - if isinstance(content, dict): - content_dict = cast(dict[str, Any], content) - # TextContent has a "text" key - if content_dict.get("type") == "text" or "text" in content_dict: - text_parts.append(str(content_dict.get("text", ""))) - else: - content_obj: object = content - if hasattr(content_obj, "text"): - text_parts.append(str(getattr(content_obj, "text", ""))) - if text_parts: - return " ".join(text_parts) - return "" - last_msg_obj: object = last_msg - if hasattr(last_msg_obj, "text"): - return str(getattr(last_msg_obj, "text", "")) + # Delegate to the shared extractor: messages can arrive in the + # flat "text" shape from this state's evaluators, the flat + # "content" shape from the fallback function module, or the + # structured .NET content list, and each must read cleanly. + return _extract_message_text(message_list[-1]) return "" def _parse_function_args(self, args_str: str) -> list[str]: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py index e5b2f4156d0..d7897c1f414 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -15,6 +15,72 @@ from typing import Any, cast +def _content_items_text(items: Any) -> str: + """Join the text entries of a structured content list. + + Covers the .NET record shape (``[{"type": "text", "value": ...}]``, + capitalized keys included) and objects exposing a text attribute. + """ + if not isinstance(items, list): + return "" + parts: list[str] = [] + for item in items: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + item_dict = cast(dict[str, Any], item) + for key in ("text", "value", "Text", "Value"): + if isinstance(item_dict.get(key), str): + parts.append(item_dict[key]) + break + elif (item_text := getattr(item, "text", None)) is not None: + parts.append(str(item_text)) + return " ".join(parts) + + +def _extract_message_text(message: Any) -> str: + """Extract the displayable text of a single message-shaped value. + + Messages reach the evaluators in three shapes: flat ``content`` (this + module's producers), flat ``text`` (DeclarativeWorkflowState's custom + evaluators), and a structured content list (the .NET record shape). + Accept all three so a message that crosses eval paths never extracts + as empty. + """ + if isinstance(message, str): + return message + if isinstance(message, dict): + message_dict = cast(dict[str, Any], message) + content: Any = message_dict.get("content") + if isinstance(content, str): + return content + text: Any = message_dict.get("text") + if isinstance(text, str): + return text + for key in ("content", "contents"): + items_text = _content_items_text(message_dict.get(key)) + if items_text: + return items_text + if content is not None and not isinstance(content, list | dict): + nested_text = getattr(content, "text", None) + if nested_text is not None: + return str(nested_text) + return str(content) if content else "" + return "" + text_attr = getattr(message, "text", None) + if text_attr is not None: + return str(text_attr) + content_attr = getattr(message, "content", None) + if isinstance(content_attr, str): + return content_attr + if content_attr is not None: + nested_attr = getattr(content_attr, "text", None) + if nested_attr is not None: + return str(nested_attr) + return str(content_attr) if content_attr else "" + return "" + + def message_text(messages: Any) -> str: """Extract text content from a message or list of messages. @@ -40,49 +106,20 @@ def message_text(messages: Any) -> str: if isinstance(messages, dict): # Single message object - messages_dict = cast(dict[str, Any], messages) - content: Any = messages_dict.get("content", "") - if isinstance(content, str): - return content - text_attr = getattr(content, "text", None) - if text_attr is not None: - return str(text_attr) - return str(content) if content else "" + return _extract_message_text(messages) if isinstance(messages, list): # List of messages - concatenate all text texts: list[str] = [] message_list = cast(list[Any], messages) for msg in message_list: - if isinstance(msg, str): - texts.append(msg) - elif isinstance(msg, dict): - msg_dict = cast(dict[str, Any], msg) - msg_content: Any = msg_dict.get("content", "") - if isinstance(msg_content, str): - texts.append(msg_content) - elif msg_content: - texts.append(str(msg_content)) - else: - msg_obj: object = msg - if hasattr(msg_obj, "content"): - msg_obj_content: Any = getattr(msg_obj, "content", None) - if isinstance(msg_obj_content, str): - texts.append(msg_obj_content) - elif (msg_obj_text := getattr(msg_obj_content, "text", None)) is not None: - texts.append(str(msg_obj_text)) - elif msg_obj_content: - texts.append(str(msg_obj_content)) + extracted = _extract_message_text(msg) + if extracted: + texts.append(extracted) return " ".join(texts) - # Try to get text attribute - if hasattr(messages, "text"): - return str(messages.text) - if hasattr(messages, "content"): - content_attr: Any = messages.content - if isinstance(content_attr, str): - return content_attr - return str(content_attr) if content_attr else "" + if hasattr(messages, "text") or hasattr(messages, "content"): + return _extract_message_text(messages) return str(messages) if messages else "" diff --git a/python/packages/declarative/tests/test_powerfx_functions.py b/python/packages/declarative/tests/test_powerfx_functions.py index 239303794cf..367336de8bd 100644 --- a/python/packages/declarative/tests/test_powerfx_functions.py +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -50,6 +50,43 @@ def test_message_text_empty_list(self): """Test that empty list returns empty string.""" assert message_text([]) == "" + def test_message_text_accepts_text_key_shape(self): + """DeclarativeWorkflowState producers emit a flat text key.""" + assert message_text([{"role": "user", "text": "hi"}]) == "hi" + + def test_message_text_accepts_structured_content_list(self): + """The .NET record shape carries text in a content list.""" + msg = {"role": "user", "content": [{"type": "text", "value": "hi"}]} + assert message_text([msg]) == "hi" + + def test_message_text_accepts_capitalized_content_items(self): + """System.Text.Json can surface PascalCase content item keys.""" + msg = {"role": "user", "content": [{"Type": "Text", "Value": "hi"}]} + assert message_text([msg]) == "hi" + + def test_message_text_accepts_contents_list(self): + """Message-dict shape stores content items under contents.""" + msg = {"role": "assistant", "contents": [{"type": "text", "text": "hi"}]} + assert message_text([msg]) == "hi" + + def test_message_text_mixed_shapes_join(self): + """A list mixing both flat shapes extracts every entry.""" + msgs = [ + {"role": "user", "text": "question"}, + {"role": "assistant", "content": "answer"}, + ] + assert message_text(msgs) == "question answer" + + def test_message_text_object_with_only_text_attr(self): + """Objects exposing text but no content still contribute.""" + + class _TextOnly: + @property + def text(self): + return "hi" + + assert message_text([_TextOnly()]) == "hi" + class TestUserMessage: """Tests for UserMessage function.""" diff --git a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py index 308982c6324..b8d4fba7a8a 100644 --- a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py +++ b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py @@ -406,6 +406,27 @@ async def test_message_text_empty_list(self, mock_state): result = state.eval("=MessageText(Local.messages)") assert result == "" + async def test_message_text_accepts_content_key_shape(self, mock_state): + """Messages from the fallback evaluator carry a flat content key.""" + state = DeclarativeWorkflowState(mock_state) + state.initialize() + + state.set("Local.messages", [{"role": "user", "content": "Hello"}]) + result = state.eval("=MessageText(Local.messages)") + assert result == "Hello" + + async def test_message_text_accepts_structured_content_list(self, mock_state): + """Messages in the .NET record shape extract their content items.""" + state = DeclarativeWorkflowState(mock_state) + state.initialize() + + state.set( + "Local.messages", + [{"role": "user", "content": [{"type": "text", "value": "Hello"}]}], + ) + result = state.eval("=MessageText(Local.messages)") + assert result == "Hello" + class TestPowerFxNestedVariables: """Test nested variable access patterns from YAML.""" From fa6bcf8001d3277595d408da446ba06d8e7e2751 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:37:59 +0800 Subject: [PATCH 2/5] Python: fix(declarative): cast iterated content items for strict pyright 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. --- .../_workflows/_powerfx_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py index d7897c1f414..6c6fcb00a36 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -24,7 +24,7 @@ def _content_items_text(items: Any) -> str: if not isinstance(items, list): return "" parts: list[str] = [] - for item in items: + for item in cast(list[Any], items): if isinstance(item, str): parts.append(item) elif isinstance(item, dict): From af683cba24d4c2b005b54d2c0eda0808056fb298 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:42:03 +0800 Subject: [PATCH 3/5] Python: fix(declarative): skip non-text .NET content records in MessageText 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. --- .../_workflows/_powerfx_functions.py | 5 +++++ .../declarative/tests/test_powerfx_functions.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py index 6c6fcb00a36..b156c656fd4 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -20,6 +20,8 @@ def _content_items_text(items: Any) -> str: Covers the .NET record shape (``[{"type": "text", "value": ...}]``, capitalized keys included) and objects exposing a text attribute. + Records declaring a non-text type (image URLs, files) are skipped: + their ``value`` is a payload reference, not displayable text. """ if not isinstance(items, list): return "" @@ -29,6 +31,9 @@ def _content_items_text(items: Any) -> str: parts.append(item) elif isinstance(item, dict): item_dict = cast(dict[str, Any], item) + declared_type = item_dict.get("type", item_dict.get("Type")) + if isinstance(declared_type, str) and declared_type.lower() != "text": + continue for key in ("text", "value", "Text", "Value"): if isinstance(item_dict.get(key), str): parts.append(item_dict[key]) diff --git a/python/packages/declarative/tests/test_powerfx_functions.py b/python/packages/declarative/tests/test_powerfx_functions.py index 367336de8bd..e00c760ba45 100644 --- a/python/packages/declarative/tests/test_powerfx_functions.py +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -69,6 +69,23 @@ def test_message_text_accepts_contents_list(self): msg = {"role": "assistant", "contents": [{"type": "text", "text": "hi"}]} assert message_text([msg]) == "hi" + def test_message_text_skips_non_text_content_records(self): + """Image/file records declare a non-text type; their value is not text.""" + msg = { + "role": "user", + "content": [ + {"type": "text", "value": "describe this"}, + {"type": "image_url", "value": "https://example.com/cat.png"}, + {"Type": "File", "Value": "data:application/pdf;base64,AAAA"}, + ], + } + assert message_text([msg]) == "describe this" + + def test_message_text_skips_untyped_payload_records(self): + """A record with neither a text type nor text keys stays empty.""" + msg = {"role": "user", "content": [{"type": "image", "value": "..."}]} + assert message_text([msg]) == "" + def test_message_text_mixed_shapes_join(self): """A list mixing both flat shapes extracts every entry.""" msgs = [ From 8fa570c275b441144fed0cf1b3c5134fd74bd90d Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:15:13 +0800 Subject: [PATCH 4/5] Python: fix(declarative): probe PascalCase top-level message keys in 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. --- .../_workflows/_powerfx_functions.py | 10 ++++++---- .../declarative/tests/test_powerfx_functions.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py index b156c656fd4..1a8fecba432 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -50,19 +50,21 @@ def _extract_message_text(message: Any) -> str: module's producers), flat ``text`` (DeclarativeWorkflowState's custom evaluators), and a structured content list (the .NET record shape). Accept all three so a message that crosses eval paths never extracts - as empty. + as empty. Top-level keys are probed in PascalCase too, because a + record serialized by .NET (``{"Role": ..., "Content": ...}``) keeps + its capitalized property names. """ if isinstance(message, str): return message if isinstance(message, dict): message_dict = cast(dict[str, Any], message) - content: Any = message_dict.get("content") + content: Any = message_dict.get("content", message_dict.get("Content")) if isinstance(content, str): return content - text: Any = message_dict.get("text") + text: Any = message_dict.get("text", message_dict.get("Text")) if isinstance(text, str): return text - for key in ("content", "contents"): + for key in ("content", "contents", "Content", "Contents"): items_text = _content_items_text(message_dict.get(key)) if items_text: return items_text diff --git a/python/packages/declarative/tests/test_powerfx_functions.py b/python/packages/declarative/tests/test_powerfx_functions.py index e00c760ba45..315121dc844 100644 --- a/python/packages/declarative/tests/test_powerfx_functions.py +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -64,6 +64,16 @@ def test_message_text_accepts_capitalized_content_items(self): msg = {"role": "user", "content": [{"Type": "Text", "Value": "hi"}]} assert message_text([msg]) == "hi" + def test_message_text_accepts_pascalcase_top_level_content(self): + """A .NET cross-runtime record keeps PascalCase top-level keys.""" + msg = {"Role": "user", "Content": [{"Type": "Text", "Value": "hi"}]} + assert message_text([msg]) == "hi" + + def test_message_text_accepts_pascalcase_flat_shapes(self): + """PascalCase flat Content/Text strings extract like lowercase.""" + assert message_text([{"Role": "user", "Content": "hi"}]) == "hi" + assert message_text([{"Role": "assistant", "Text": "hello"}]) == "hello" + def test_message_text_accepts_contents_list(self): """Message-dict shape stores content items under contents.""" msg = {"role": "assistant", "contents": [{"type": "text", "text": "hi"}]} From 70c4223293a53f2771bca264c1797f33759fa0e3 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:42:10 +0800 Subject: [PATCH 5/5] Python: fix(declarative): gate object content items by type in MessageText 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. --- .../_workflows/_powerfx_functions.py | 13 ++++++++++--- .../declarative/tests/test_powerfx_functions.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py index 1a8fecba432..1dc57920ca1 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -21,7 +21,9 @@ def _content_items_text(items: Any) -> str: Covers the .NET record shape (``[{"type": "text", "value": ...}]``, capitalized keys included) and objects exposing a text attribute. Records declaring a non-text type (image URLs, files) are skipped: - their ``value`` is a payload reference, not displayable text. + their ``value`` is a payload reference, not displayable text. Object + items are gated the same way core ``Message.text`` gates contents, so + a reasoning block's ``.text`` does not leak into the extraction. """ if not isinstance(items, list): return "" @@ -38,8 +40,13 @@ def _content_items_text(items: Any) -> str: if isinstance(item_dict.get(key), str): parts.append(item_dict[key]) break - elif (item_text := getattr(item, "text", None)) is not None: - parts.append(str(item_text)) + else: + declared_type = getattr(item, "type", None) + if isinstance(declared_type, str) and declared_type.lower() != "text": + continue + item_text = getattr(item, "text", None) + if item_text is not None: + parts.append(str(item_text)) return " ".join(parts) diff --git a/python/packages/declarative/tests/test_powerfx_functions.py b/python/packages/declarative/tests/test_powerfx_functions.py index 315121dc844..c892743c1f4 100644 --- a/python/packages/declarative/tests/test_powerfx_functions.py +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -96,6 +96,19 @@ def test_message_text_skips_untyped_payload_records(self): msg = {"role": "user", "content": [{"type": "image", "value": "..."}]} assert message_text([msg]) == "" + def test_message_text_skips_reasoning_content_objects(self): + """Core Content objects gate on type == "text", like Message.text.""" + from agent_framework import Content + + msg = { + "role": "assistant", + "content": [ + Content.from_text_reasoning(text="chain of thought"), + Content.from_text(text="final answer"), + ], + } + assert message_text([msg]) == "final answer" + def test_message_text_mixed_shapes_join(self): """A list mixing both flat shapes extracts every entry.""" msgs = [