Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions python/packages/declarative/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
81 changes: 71 additions & 10 deletions python/packages/declarative/tests/test_function_tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
ActionComplete,
ActionTrigger,
DeclarativeWorkflowBuilder,
DeclarativeWorkflowState,
InvokeFunctionToolExecutor,
ToolApprovalRequest,
ToolApprovalResponse,
Expand Down Expand Up @@ -580,22 +581,38 @@ 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

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"}},
],
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading