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 c6a83c93a6..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 @@ -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.""" @@ -117,14 +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" 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) @@ -143,18 +155,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 +183,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 +203,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! """ 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)