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 7338aa62260..c5b9bd35a93 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -47,6 +47,7 @@ from ._errors import DeclarativeWorkflowError from ._powerfx_functions import ( + _extract_message_text, # pyright: ignore[reportPrivateUsage] _iter_powerfx_expression_indices, # pyright: ignore[reportPrivateUsage] whole_call_args, ) @@ -791,34 +792,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 ddaad1e299f..1a8dfdb3c8f 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -161,6 +161,85 @@ def whole_call_args(formula: str, *names: str) -> str | None: return formula[len(prefix) : close_index] return None +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. + Records declaring a non-text type (image URLs, files) are skipped: + 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 "" + parts: list[str] = [] + for item in cast(list[Any], items): + if isinstance(item, 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]) + break + 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) + + +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. 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", message_dict.get("Content")) + if isinstance(content, str): + return content + text: Any = message_dict.get("text", message_dict.get("Text")) + if isinstance(text, str): + return text + for key in ("content", "contents", "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. @@ -187,49 +266,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 8927538605a..28691cc8dfa 100644 --- a/python/packages/declarative/tests/test_powerfx_functions.py +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -54,6 +54,83 @@ 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_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"}]} + 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_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 = [ + {"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."""