diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md index 42a2a2bc305..4654f84859b 100644 --- a/python/packages/declarative/README.md +++ b/python/packages/declarative/README.md @@ -21,6 +21,24 @@ This package ships at two different stability levels: The declarative packages provides support for building agents based on a declarative yaml specification. +## Workflow automatic output + +For `InvokeFunctionTool` and `InvokeAzureAgent`, `output.autoSend` accepts a +Boolean or a `=`-prefixed PowerFx Boolean expression evaluated against the current +workflow state. Omitting it defaults to `true`. + +```yaml +output: + autoSend: =Local.publishResult +``` + +A false value suppresses automatic workflow output, not the invocation or storage +of its results. Output variables and agent conversation history remain available; +later actions such as `SendActivity` can explicitly emit those results. + +Tool actions evaluate the setting again when resuming after approval. Agent +external loops retain the evaluated setting for the duration of the loop. + ## HTTP request client ownership and cookies **Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index a7dfe340bf6..42a327b2f41 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -615,8 +615,8 @@ def _get_input_config(self) -> tuple[dict[str, Any], Any, str | None, int]: return arguments, messages, external_loop_when, max_iterations - def _get_output_config(self) -> tuple[str | None, str | None, str | None, bool]: - """Parse output configuration. + def _get_output_config(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None, str | None, bool]: + """Parse output bindings and evaluate autoSend against the current state. Returns: Tuple of (messages var, responseObject var, resultProperty, autoSend) @@ -637,7 +637,7 @@ def _get_output_config(self) -> tuple[str | None, str | None, str | None, bool]: property_val: Any = output_dict.get("property") property_var: str | None = str(property_val) if property_val is not None else None auto_send_val: Any = output_dict.get("autoSend", True) - auto_send: bool = bool(auto_send_val) + auto_send: bool = bool(state.eval_if_expression(auto_send_val)) return messages_var, response_obj_var, property_var or result_property, auto_send @@ -925,7 +925,7 @@ async def handle_action( logger.debug("handle_action: starting agent '%s'", agent_name) arguments, messages_expr, external_loop_when, max_iterations = self._get_input_config() - messages_var, response_obj_var, result_property, auto_send = self._get_output_config() + messages_var, response_obj_var, result_property, auto_send = self._get_output_config(state) # Get conversation-specific messages path if conversationId is specified conversation_id_expr = self._get_conversation_id() diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py index 163d22824e6..be04de1d01c 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_tools.py @@ -243,8 +243,8 @@ def _get_tool( return None - def _get_output_config(self) -> tuple[str | None, str | None, bool]: - """Parse output configuration from action definition. + def _get_output_config(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None, bool]: + """Parse output bindings and evaluate autoSend against the current state. Returns: Tuple of (messages_var, result_var, auto_send) @@ -256,7 +256,7 @@ def _get_output_config(self) -> tuple[str | None, str | None, bool]: messages_var = output_config.get("messages") result_var = output_config.get("result") - auto_send = bool(output_config.get("autoSend", True)) + auto_send = bool(state.eval_if_expression(output_config.get("autoSend", True))) return ( str(messages_var) if messages_var else None, str(result_var) if result_var else None, @@ -443,7 +443,7 @@ async def handle_action( state = await self._ensure_state_initialized(ctx, trigger) # Parse output configuration early so we can store errors - messages_var, result_var, auto_send = self._get_output_config() + messages_var, result_var, auto_send = self._get_output_config(state) # Get and evaluate function name (required) function_name_expr = self._action_def.get("functionName") @@ -526,7 +526,7 @@ async def handle_approval_response( function_name = original_request.function_name arguments = original_request.arguments - messages_var, result_var, auto_send = self._get_output_config() + messages_var, result_var, auto_send = self._get_output_config(state) # Check if approved if response.approved is not True: diff --git a/python/packages/declarative/tests/test_function_tool_executor.py b/python/packages/declarative/tests/test_function_tool_executor.py index 791fcaa4b1d..ce1ba6c2c11 100644 --- a/python/packages/declarative/tests/test_function_tool_executor.py +++ b/python/packages/declarative/tests/test_function_tool_executor.py @@ -38,6 +38,7 @@ ActionComplete, ActionTrigger, DeclarativeWorkflowBuilder, + DeclarativeWorkflowState, InvokeFunctionToolExecutor, ToolApprovalRequest, ToolApprovalResponse, @@ -580,9 +581,24 @@ def sum_list(numbers: list) -> int: assert any("15" in out for out in outputs) - @pytest.mark.asyncio - async def test_auto_send_disabled(self): - """Test autoSend=false prevents automatic output yielding.""" + @pytest.mark.parametrize( + ("output_config", "expected_auto_send"), + [ + ({}, True), + ({"autoSend": True}, True), + ({"autoSend": False}, False), + ({"autoSend": None}, False), + ({"autoSend": "=true"}, True), + ({"autoSend": "=false"}, False), + ({"autoSend": "=Local.send"}, False), + ({"autoSend": "=Not(Local.send)"}, True), + ({"autoSend": "=Blank()"}, False), + ({"autoSend": "=Local.missing"}, False), + ({"autoSend": "false"}, True), + ], + ) + async def test_auto_send(self, output_config: dict[str, Any], expected_auto_send: bool) -> None: + """Evaluate autoSend without suppressing explicitly requested output.""" def echo_id(msg: str) -> str: return msg @@ -590,12 +606,13 @@ def echo_id(msg: str) -> str: yaml_def = { "name": "auto_send_disabled_test", "actions": [ + {"kind": "SetValue", "id": "set_send", "path": "Local.send", "value": False}, { "kind": "InvokeFunctionTool", "id": "call_no_auto_send", "functionName": "echo_id", "arguments": {"msg": "hello"}, - "output": {"result": "Local.result", "autoSend": False}, + "output": {"result": "Local.result", **output_config}, }, {"kind": "SendActivity", "id": "output", "activity": {"text": "=Local.result"}}, ], @@ -607,8 +624,7 @@ def echo_id(msg: str) -> str: events = await workflow.run({}) outputs = events.get_outputs() - # Result should still be available via explicit SendActivity - assert "hello" in outputs + assert outputs == ["hello"] * (2 if expected_auto_send else 1) @pytest.mark.asyncio async def test_function_with_only_result_output(self): @@ -828,7 +844,7 @@ class TestNonDictOutputConfig: """Tests for non-dict output config handling.""" @pytest.mark.asyncio - async def test_output_as_string_is_ignored(self): + async def test_output_as_string_is_ignored(self, mock_state: MagicMock): """When output is a string instead of dict, both vars should be None.""" def noop() -> str: @@ -843,13 +859,13 @@ def noop() -> str: } executor = InvokeFunctionToolExecutor(action_def, tools={"noop": noop}) - messages_var, result_var, auto_send = executor._get_output_config() + messages_var, result_var, auto_send = executor._get_output_config(DeclarativeWorkflowState(mock_state)) assert messages_var is None assert result_var is None assert auto_send is True @pytest.mark.asyncio - async def test_output_as_list_is_ignored(self): + async def test_output_as_list_is_ignored(self, mock_state: MagicMock): """When output is a list instead of dict, both vars should be None.""" def noop() -> str: @@ -864,7 +880,7 @@ def noop() -> str: } executor = InvokeFunctionToolExecutor(action_def, tools={"noop": noop}) - messages_var, result_var, auto_send = executor._get_output_config() + messages_var, result_var, auto_send = executor._get_output_config(DeclarativeWorkflowState(mock_state)) assert messages_var is None assert result_var is None assert auto_send is True @@ -1038,6 +1054,51 @@ def _init_state(self, mock_state: MagicMock) -> None: "Conversation": {"messages": [], "history": []}, } + @pytest.mark.parametrize("approved", [True, False]) + @pytest.mark.parametrize("send", [True, False]) + async def test_auto_send_on_approval_resume( + self, mock_state: MagicMock, mock_context: MagicMock, approved: bool, send: bool + ) -> None: + state = DeclarativeWorkflowState(mock_state) + state.initialize() + state.set("Local.send", not send) + tool = MagicMock(return_value="hello") + executor = InvokeFunctionToolExecutor( + { + "kind": "InvokeFunctionTool", + "id": "auto_send_approval", + "functionName": "echo", + "requireApproval": True, + "output": { + "result": "Local.result", + "messages": "Local.messages", + "autoSend": "=Local.send", + }, + }, + tools={"echo": tool}, + ) + + await executor.handle_action(ActionTrigger(), mock_context) + tool.assert_not_called() + mock_context.yield_output.assert_not_awaited() + request = mock_context.request_info.call_args[0][0] + state.set("Local.send", send) + + await executor.handle_approval_response(request, ToolApprovalResponse(approved=approved), mock_context) + + if approved: + tool.assert_called_once_with() + assert state.get("Local.result") == "hello" + assert len(state.get("Local.messages")) == 2 + else: + tool.assert_not_called() + assert state.get("Local.result")["rejected"] is True + if approved and send: + mock_context.yield_output.assert_awaited_once_with("hello") + else: + mock_context.yield_output.assert_not_awaited() + mock_context.send_message.assert_awaited_once() + @pytest.mark.asyncio async def test_approval_required_emits_request(self, mock_state, mock_context): """When requireApproval=true, handle_action should emit ToolApprovalRequest and return.""" diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index 2d0b5cae2b1..b91189e039d 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -836,7 +836,9 @@ async def test_agent_executor_get_output_config_simple(self, mock_context, mock_ } executor = InvokeAzureAgentExecutor(action_def) - messages_var, response_obj, result_prop, auto_send = executor._get_output_config() + messages_var, response_obj, result_prop, auto_send = executor._get_output_config( + DeclarativeWorkflowState(mock_state) + ) assert messages_var is None assert response_obj is None assert result_prop == "Local.result" @@ -860,7 +862,9 @@ async def test_agent_executor_get_output_config_full(self, mock_context, mock_st } executor = InvokeAzureAgentExecutor(action_def) - messages_var, response_obj, result_prop, auto_send = executor._get_output_config() + messages_var, response_obj, result_prop, auto_send = executor._get_output_config( + DeclarativeWorkflowState(mock_state) + ) assert messages_var == "Local.ResponseMessages" assert response_obj == "Local.ParsedResponse" assert result_prop == "Local.result" @@ -1774,6 +1778,143 @@ async def test_request_external_input_reads_top_level_alternates(self, mock_cont class TestAgentExternalLoopCoverage: """Tests for agent executor external loop handling.""" + @_requires_powerfx + @pytest.mark.parametrize("string_result", [False, True]) + @pytest.mark.parametrize( + ("output_config", "expected_auto_send"), + [ + ({}, True), + ({"autoSend": True}, True), + ({"autoSend": False}, False), + ({"autoSend": None}, False), + ({"autoSend": "=true"}, True), + ({"autoSend": "=false"}, False), + ({"autoSend": "=Local.send"}, False), + ({"autoSend": "=Not(Local.send)"}, True), + ({"autoSend": "=Blank()"}, False), + ({"autoSend": "=Local.missing"}, False), + ({"autoSend": "false"}, True), + ], + ) + async def test_agent_auto_send( + self, + mock_context: MagicMock, + mock_state: MagicMock, + output_config: dict[str, Any], + expected_auto_send: bool, + string_result: bool, + ) -> None: + from types import SimpleNamespace + + from agent_framework_declarative._workflows._executors_agents import InvokeAzureAgentExecutor + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + state.set("Local.send", False) + result = "hello" if string_result else SimpleNamespace(text="hello", messages=[], tool_calls=[]) + agent = MagicMock(run=AsyncMock(return_value=result)) + executor = InvokeAzureAgentExecutor( + { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "input": "question", + "resultProperty": "Local.result", + "output": {"messages": "Local.messages", **output_config}, + }, + agents={"TestAgent": agent}, + ) + + await executor.handle_action(ActionTrigger(), mock_context) + + agent.run.assert_awaited_once() + if expected_auto_send: + mock_context.yield_output.assert_awaited_once_with("hello") + else: + mock_context.yield_output.assert_not_awaited() + assert state.get("Local.result") == "hello" + assert state.get("Local.messages") == "hello" + assert state.get("Conversation.messages")[-1].text == "hello" + mock_context.send_message.assert_awaited_once() + + @_requires_powerfx + @pytest.mark.parametrize("send", [False, True]) + async def test_agent_auto_send_on_external_loop_resume( + self, mock_context: MagicMock, mock_state: MagicMock, send: bool + ) -> None: + from agent_framework_declarative._workflows._executors_agents import ( + AgentExternalInputResponse, + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + state.set("Local.send", send) + agent = MagicMock(run=AsyncMock(return_value="hello")) + executor = InvokeAzureAgentExecutor( + { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "input": {"externalLoop": {"when": "=true"}}, + "output": {"property": "Local.result", "autoSend": "=Local.send"}, + }, + agents={"TestAgent": agent}, + ) + + await executor.handle_action(ActionTrigger(), mock_context) + request = mock_context.request_info.call_args[0][0] + state.set("Local.send", not send) + mock_context.yield_output.reset_mock() + + await executor.handle_external_input_response( + request, AgentExternalInputResponse(user_input="continue"), mock_context + ) + + assert agent.run.await_count == 2 + if send: + mock_context.yield_output.assert_awaited_once_with("hello") + else: + mock_context.yield_output.assert_not_awaited() + assert state.get("Local.result") == "hello" + + @_requires_powerfx + @pytest.mark.parametrize("kind", ["InvokeFunctionTool", "InvokeAzureAgent"]) + @pytest.mark.parametrize("missing_engine", [False, True]) + async def test_auto_send_evaluation_error_prevents_invocation( + self, + mock_context: MagicMock, + mock_state: MagicMock, + monkeypatch: pytest.MonkeyPatch, + kind: str, + missing_engine: bool, + ) -> None: + from agent_framework_declarative._workflows._executors_agents import InvokeAzureAgentExecutor + from agent_framework_declarative._workflows._executors_tools import InvokeFunctionToolExecutor + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + tool = MagicMock(return_value="hello") + agent = MagicMock(run=AsyncMock(return_value="hello")) + action_def = { + "kind": kind, + "agent": "TestAgent", + "functionName": "echo", + "output": {"autoSend": "=true" if missing_engine else "=1 +"}, + } + executor = ( + InvokeFunctionToolExecutor(action_def, tools={"echo": tool}) + if kind == "InvokeFunctionTool" + else InvokeAzureAgentExecutor(action_def, agents={"TestAgent": agent}) + ) + if missing_engine: + monkeypatch.setattr("agent_framework_declarative._workflows._declarative_base.Engine", None) + + with pytest.raises(RuntimeError if missing_engine else ValueError): + await executor.handle_action(ActionTrigger(), mock_context) + + tool.assert_not_called() + agent.run.assert_not_awaited() + mock_context.yield_output.assert_not_awaited() + @_requires_powerfx async def test_agent_executor_with_external_loop(self, mock_context, mock_state): """Test agent executor with external loop that triggers."""