From 8b649e368daa572ed2c92abe15621438603368e4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 24 Mar 2026 01:27:36 +0000 Subject: [PATCH 1/4] Add set_stop_loss tool to concurrent_builder_tool_approval sample Add a second approval-gated tool (set_stop_loss) to the concurrent workflow tool approval sample to demonstrate handling approval requests for different tools in the same concurrent workflow. Changes: - Add set_stop_loss(symbol, stop_price) with approval_mode='always_require' - Include new tool in both agents' tool lists - Update agent instructions and prompt to encourage stop-loss usage - Update docstring to reflect two approval-gated tools - Update sample output to show mixed approval requests Fixes #4874 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../concurrent_builder_tool_approval.py | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py index c6a83c93a6..f24b2632f8 100644 --- a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py @@ -28,19 +28,19 @@ This sample works as follows: 1. A ConcurrentBuilder workflow is created with two agents running in parallel. -2. Both agents have the same tools, including one requiring approval (execute_trade). +2. Both agents have the same tools, including two requiring approval (execute_trade, set_stop_loss). 3. Both agents receive the same task and work concurrently on their respective stocks. -4. When either agent tries to execute a trade, it triggers an approval request. +4. When either agent tries to execute a trade or set a stop-loss, it triggers an approval request. 5. The sample simulates human approval and the workflow completes. 6. Results from both agents are aggregated and output. Purpose: Show how tool call approvals work in parallel execution scenarios where multiple -agents may independently trigger approval requests. +agents may independently trigger approval requests for different tools. Demonstrate: - Handling multiple approval requests from different agents in concurrent workflows. -- Handling during concurrent agent execution. +- Handling approval requests for different tools during concurrent agent execution. - Understanding that approval pauses only the agent that triggered it, not all agents. Prerequisites: @@ -88,6 +88,15 @@ def execute_trade( return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}" +@tool(approval_mode="always_require") +def set_stop_loss( + symbol: Annotated[str, "The stock ticker symbol"], + stop_price: Annotated[float, "The stop-loss price"], +) -> str: + """Set a stop-loss order for a stock. Requires human approval due to financial impact.""" + return f"Stop-loss set for {symbol.upper()} at ${stop_price:.2f}" + + @tool(approval_mode="never_require") def get_portfolio_balance() -> str: """Get current portfolio balance and available funds.""" @@ -143,18 +152,20 @@ async def main() -> None: name="MicrosoftAgent", instructions=( "You are a personal trading assistant focused on Microsoft (MSFT). " - "You manage my portfolio and take actions based on market data." + "You manage my portfolio and take actions based on market data. " + "Use stop-loss orders to manage risk." ), - tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade], + tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss], ) google_agent = client.as_agent( name="GoogleAgent", instructions=( "You are a personal trading assistant focused on Google (GOOGL). " - "You manage my trades and portfolio based on market conditions." + "You manage my trades and portfolio based on market conditions. " + "Use stop-loss orders to manage risk." ), - tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade], + tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss], ) # 4. Build a concurrent workflow with both agents @@ -169,7 +180,8 @@ async def main() -> None: # Runs are not isolated; state is preserved across multiple calls to run. stream = workflow.run( "Manage my portfolio. Use a max of 5000 dollars to adjust my position using " - "your best judgment based on market sentiment. No need to confirm trades with me.", + "your best judgment based on market sentiment. Set stop-loss orders to manage risk. " + "No need to confirm trades with me.", stream=True, ) @@ -188,22 +200,32 @@ async def main() -> None: Approval requested for tool: execute_trade Arguments: {"symbol":"MSFT","action":"buy","quantity":13} + Approval requested for tool: set_stop_loss + Arguments: {"symbol":"MSFT","stop_price":340.0} + Approval requested for tool: execute_trade Arguments: {"symbol":"GOOGL","action":"buy","quantity":35} + Approval requested for tool: set_stop_loss + Arguments: {"symbol":"GOOGL","stop_price":126.0} + Simulating human approval for: execute_trade + Simulating human approval for: set_stop_loss + Simulating human approval for: execute_trade + Simulating human approval for: set_stop_loss + ------------------------------------------------------------ Workflow completed. Aggregated results from both agents: - user: Manage my portfolio. Use a max of 5000 dollars to adjust my position using your best judgment based on - market sentiment. No need to confirm trades with me. - - MicrosoftAgent: I have successfully executed the trade, purchasing 13 shares of Microsoft (MSFT). This action - was based on the positive market sentiment and available funds within the specified limit. - Your portfolio has been adjusted accordingly. - - GoogleAgent: I have successfully executed the trade, purchasing 35 shares of GOOGL. If you need further - assistance or any adjustments, feel free to ask! + market sentiment. Set stop-loss orders to manage risk. No need to confirm trades with me. + - MicrosoftAgent: I have successfully purchased 13 shares of Microsoft (MSFT) and set a stop-loss at $340.00. + This action was based on the positive market sentiment and available funds within the + specified limit. Your portfolio has been adjusted accordingly. + - GoogleAgent: I have successfully purchased 35 shares of GOOGL and set a stop-loss at $126.00. If you need + further assistance or any adjustments, feel free to ask! """ From 27d7b7bfda32d57b7740234e44e05ca7a79f729b Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 24 Mar 2026 01:36:40 +0000 Subject: [PATCH 2/4] Print tool name and arguments in concurrent sample's process_event_stream (#4874) Align process_event_stream in concurrent_builder_tool_approval.py to print the tool name and arguments when collecting approval requests, matching the sample output comment and the sequential_builder_tool_approval.py pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tool-approval/concurrent_builder_tool_approval.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py index f24b2632f8..c3b3687cab 100644 --- a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py @@ -126,6 +126,9 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str if event.type == "request_info" and isinstance(event.data, Content): # We are only expecting tool approval requests in this sample requests[event.request_id] = event.data + if event.data.type == "function_approval_request": + print(f"\nApproval requested for tool: {event.data.function_call.name}") # type: ignore + print(f"Arguments: {event.data.function_call.arguments}") # type: ignore elif event.type == "output": _print_output(event) From da6f7c484c61ae2632630a607da4b6473f7c881c Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 24 Mar 2026 01:46:33 +0000 Subject: [PATCH 3/4] Add None-guard for function_call access in tool approval sample (#4874) Add explicit None-checks before accessing function_call.name and function_call.arguments in concurrent_builder_tool_approval.py. The function_call field is typed Content | None, so direct attribute access without a guard could raise AttributeError and required type: ignore comments. The None-guard is consistent with the pattern used in _agent_run.py and removes the suppression comments. Also add a regression test verifying that function_call defaults to None and that the None-guard pattern is safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/tests/core/test_types.py | 15 +++++++++++++++ .../concurrent_builder_tool_approval.py | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 1cde898787..a51dd05d73 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -661,6 +661,21 @@ def test_function_approval_serialization_roundtrip(): # The Content union will need to be handled differently when we fully migrate +def test_function_approval_request_function_call_none_guard(): + """Test that accessing function_call attributes is safe when function_call is None.""" + # Construct a Content with type "function_approval_request" but no function_call. + # This verifies the None-guard pattern used in samples to prevent AttributeError. + content = Content("function_approval_request", id="req-none") + assert content.function_call is None + + # A proper approval request always has function_call set + fc = Content.from_function_call(call_id="call-1", name="do_something", arguments={"a": 1}) + req = Content.from_function_approval_request(id="req-1", function_call=fc) + assert req.function_call is not None + assert req.function_call.name == "do_something" + assert req.function_call.arguments == {"a": 1} + + def test_function_approval_accepts_mcp_call(): """Ensure FunctionApprovalRequestContent supports MCP server tool calls.""" mcp_call = Content.from_mcp_server_tool_call( diff --git a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py index c3b3687cab..80b313f5f1 100644 --- a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py @@ -126,17 +126,17 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str if event.type == "request_info" and isinstance(event.data, Content): # We are only expecting tool approval requests in this sample requests[event.request_id] = event.data - if event.data.type == "function_approval_request": - print(f"\nApproval requested for tool: {event.data.function_call.name}") # type: ignore - print(f"Arguments: {event.data.function_call.arguments}") # type: ignore + if event.data.type == "function_approval_request" and event.data.function_call is not None: + print(f"\nApproval requested for tool: {event.data.function_call.name}") + print(f"Arguments: {event.data.function_call.arguments}") elif event.type == "output": _print_output(event) responses: dict[str, Content] = {} if requests: for request_id, request in requests.items(): - if request.type == "function_approval_request": - print(f"\nSimulating human approval for: {request.function_call.name}") # type: ignore + if request.type == "function_approval_request" and request.function_call is not None: + print(f"\nSimulating human approval for: {request.function_call.name}") # Create approval response responses[request_id] = request.to_function_approval_response(approved=True) From 5b09604b4a8564a24de9f50d938953cd96b20480 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 24 Mar 2026 01:47:22 +0000 Subject: [PATCH 4/4] Apply same function_call None-guard to sibling tool-approval samples (#4874) Apply the same fix to sequential_builder_tool_approval.py and group_chat_builder_tool_approval.py, which had the identical pattern of accessing function_call.name/arguments without a None-guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tool-approval/group_chat_builder_tool_approval.py | 8 ++++---- .../tool-approval/sequential_builder_tool_approval.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py index 7f384bb4cd..01e7f7fd54 100644 --- a/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py @@ -120,11 +120,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str responses: dict[str, Content] = {} if requests: for request_id, request in requests.items(): - if request.type == "function_approval_request": + if request.type == "function_approval_request" and request.function_call is not None: print("\n[APPROVAL REQUIRED]") - print(f" Tool: {request.function_call.name}") # type: ignore - print(f" Arguments: {request.function_call.arguments}") # type: ignore - print(f"Simulating human approval for: {request.function_call.name}") # type: ignore + print(f" Tool: {request.function_call.name}") + print(f" Arguments: {request.function_call.arguments}") + print(f"Simulating human approval for: {request.function_call.name}") # Create approval response responses[request_id] = request.to_function_approval_response(approved=True) diff --git a/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py index c3ad0cf011..282ce783c5 100644 --- a/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py @@ -93,11 +93,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str responses: dict[str, Content] = {} if requests: for request_id, request in requests.items(): - if request.type == "function_approval_request": + if request.type == "function_approval_request" and request.function_call is not None: print("\n[APPROVAL REQUIRED]") - print(f" Tool: {request.function_call.name}") # type: ignore - print(f" Arguments: {request.function_call.arguments}") # type: ignore - print(f"Simulating human approval for: {request.function_call.name}") # type: ignore + print(f" Tool: {request.function_call.name}") + print(f" Arguments: {request.function_call.arguments}") + print(f"Simulating human approval for: {request.function_call.name}") # Create approval response responses[request_id] = request.to_function_approval_response(approved=True)