"""Integration tests for the FastAPI metadata endpoint.""" import json from datetime import datetime from typing import cast import pytest pytest.importorskip("fastapi.testclient") from agents import ( CodeInterpreterTool, FileSearchTool, RunContextWrapper, WebSearchTool, function_tool as sdk_function_tool, ) from agents.tool_context import ToolContext from fastapi.testclient import TestClient from openai.types.responses.tool_param import CodeInterpreter from pydantic import BaseModel from agency_swarm import Agency, Agent, BaseTool, function_tool, run_fastapi from agency_swarm.integrations.fastapi_utils import endpoint_handlers, tool_endpoints from agency_swarm.tools import ToolFactory @pytest.fixture def agency_factory(): """Provide a simple agency factory for FastAPI tests.""" def create_agency(load_threads_callback=None, save_threads_callback=None): agent = Agent(name="MetadataAgent", instructions="Return metadata") return Agency( agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback, ) return create_agency def test_metadata_endpoint_includes_version(monkeypatch, agency_factory): """Verify that the metadata endpoint includes the agency-swarm version.""" expected_version = "9.9.9" monkeypatch.setattr(endpoint_handlers, "_get_agency_swarm_version", lambda: expected_version) app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="") client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert payload["agency_swarm_version"] == expected_version assert "allowed_local_file_dirs" in payload def test_metadata_endpoint_omits_missing_version(monkeypatch, agency_factory): """Ensure missing version information is not added to the payload.""" monkeypatch.setattr(endpoint_handlers, "_get_agency_swarm_version", lambda: None) app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="") client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert "agency_swarm_version" not in payload assert "allowed_local_file_dirs" in payload def test_metadata_includes_agent_capabilities(): """Verify that metadata includes capabilities for each agent.""" class CustomTool(BaseTool): """Custom tool for testing.""" def run(self) -> str: return "custom" @function_tool def sample_function() -> str: """Sample function tool.""" return "sample" def create_agency(load_threads_callback=None, save_threads_callback=None): # Agent with custom tools agent1 = Agent(name="ToolAgent", instructions="Test", tools=[CustomTool, sample_function]) # Agent with hosted tools agent2 = Agent( name="HostedAgent", instructions="Test", tools=[ FileSearchTool(vector_store_ids=["vs_123"]), CodeInterpreterTool(tool_config=CodeInterpreter()), WebSearchTool(), ], ) agent3 = Agent( name="ReasoningAgent", instructions="Test", model="gpt-5.4-mini", ) agent4 = Agent( name="FullAgent", instructions="Test", model="gpt-5.4-mini", tools=[CustomTool, FileSearchTool(vector_store_ids=["vs_456"])], ) return Agency( agent1, communication_flows=[(agent1, agent2), (agent1, agent3), (agent1, agent4)], load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback, ) app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="") client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert "allowed_local_file_dirs" in payload # Find agents in nodes nodes = payload.get("nodes", []) assert len(nodes) > 0 # Find specific agents and verify capabilities tool_agent = next((n for n in nodes if n["id"] == "ToolAgent"), None) assert tool_agent is not None assert "capabilities" in tool_agent["data"] assert "tools" in tool_agent["data"]["capabilities"] hosted_agent = next((n for n in nodes if n["id"] == "HostedAgent"), None) assert hosted_agent is not None assert "capabilities" in hosted_agent["data"] capabilities = set(hosted_agent["data"]["capabilities"]) assert capabilities == {"file_search", "code_interpreter", "web_search", "reasoning"} assert "tools" not in capabilities reasoning_agent = next((n for n in nodes if n["id"] == "ReasoningAgent"), None) assert reasoning_agent is not None assert "capabilities" in reasoning_agent["data"] assert "reasoning" in reasoning_agent["data"]["capabilities"] full_agent = next((n for n in nodes if n["id"] == "FullAgent"), None) assert full_agent is not None assert "capabilities" in full_agent["data"] capabilities = set(full_agent["data"]["capabilities"]) assert "tools" in capabilities assert "reasoning" in capabilities assert "file_search" in capabilities def test_metadata_capabilities_empty_for_basic_agent(): """Agent with no tools only reports reasoning from the default model.""" def create_agency(load_threads_callback=None, save_threads_callback=None): agent = Agent(name="BasicAgent", instructions="Basic agent with no tools") return Agency(agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback) app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="") client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert "allowed_local_file_dirs" in payload nodes = payload.get("nodes", []) basic_agent = next((n for n in nodes if n["id"] == "BasicAgent"), None) assert basic_agent is not None assert "capabilities" in basic_agent["data"] assert basic_agent["data"]["capabilities"] == ["reasoning"] def test_metadata_includes_allowed_local_file_dirs(tmp_path, agency_factory): """Metadata should expose the allowed local file directories configuration.""" allowed_dir = tmp_path / "uploads" allowed_dir.mkdir(parents=True, exist_ok=True) app = run_fastapi( agencies={"test_agency": agency_factory}, return_app=True, app_token_env="", allowed_local_file_dirs=[str(allowed_dir)], ) client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert payload["allowed_local_file_dirs"] == [str(allowed_dir.resolve())] def test_metadata_includes_quick_replies() -> None: """Metadata should expose both starters and quick replies for UI clients.""" def create_agency(load_threads_callback=None, save_threads_callback=None): agent = Agent( name="QuickRepliesAgent", instructions="Use quick replies", conversation_starters=["Support: I need help with billing"], quick_replies=["hi", "hello"], ) return Agency( agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback, ) app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="") client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() nodes = payload.get("nodes", []) quick_agent = next((n for n in nodes if n["id"] == "QuickRepliesAgent"), None) assert quick_agent is not None data = quick_agent["data"] assert data["conversationStarters"] == ["Support: I need help with billing"] assert data["quickReplies"] == ["hi", "hello"] def test_metadata_endpoint_reads_live_metadata(): """Metadata should reflect the current agency factory state on each request.""" state = {"quick": ["hi"]} def create_agency(load_threads_callback=None, save_threads_callback=None): agent = Agent( name="LiveMetadataAgent", instructions="Use quick replies", quick_replies=list(state["quick"]), ) return Agency( agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback, ) app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="") client = TestClient(app) first = client.get("/test_agency/get_metadata") assert first.status_code == 200 first_agent = next((n for n in first.json().get("nodes", []) if n["id"] == "LiveMetadataAgent"), None) assert first_agent is not None assert first_agent["data"]["quickReplies"] == ["hi"] state["quick"] = ["bye"] second = client.get("/test_agency/get_metadata") assert second.status_code == 200 second_agent = next((n for n in second.json().get("nodes", []) if n["id"] == "LiveMetadataAgent"), None) assert second_agent is not None assert second_agent["data"]["quickReplies"] == ["bye"] def test_metadata_includes_tool_input_schema(): """Metadata should include input schema for function tools when available.""" @function_tool def sample_tool(text: str) -> str: return text def create_agency(load_threads_callback=None, save_threads_callback=None): agent = Agent(name="SchemaAgent", instructions="Test", tools=[sample_tool]) return Agency(agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback) app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="") client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() schema_agent = next((n for n in payload.get("nodes", []) if n["id"] == "SchemaAgent"), None) assert schema_agent is not None tools = schema_agent["data"].get("tools", []) assert tools input_schema = tools[0].get("inputSchema") assert isinstance(input_schema, dict) assert "text" in input_schema.get("properties", {}) def test_metadata_includes_missing_allowed_dirs(tmp_path, agency_factory): """Missing allowed local file directories should still appear in metadata.""" missing_dir = tmp_path / "missing-uploads" app = run_fastapi( agencies={"test_agency": agency_factory}, return_app=True, app_token_env="", allowed_local_file_dirs=[str(missing_dir)], ) client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert payload["allowed_local_file_dirs"] == [str(missing_dir)] def test_metadata_includes_non_directory_allowed_dirs(tmp_path, agency_factory): """Non-directory allowlist entries should appear in metadata as configured.""" file_entry = tmp_path / "not-a-directory.txt" file_entry.write_text("x", encoding="utf-8") app = run_fastapi( agencies={"test_agency": agency_factory}, return_app=True, app_token_env="", allowed_local_file_dirs=[str(file_entry)], ) client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() assert payload["allowed_local_file_dirs"] == [str(file_entry)] def test_metadata_returns_absolute_allowlist_paths(tmp_path, monkeypatch, agency_factory): """Metadata should return allowlist entries as absolute paths.""" from pathlib import Path allowed_dir = tmp_path / "uploads" allowed_dir.mkdir(parents=True, exist_ok=True) app = run_fastapi( agencies={"test_agency": agency_factory}, return_app=True, app_token_env="", allowed_local_file_dirs=[str(allowed_dir)], ) client = TestClient(app) response = client.get("/test_agency/get_metadata") assert response.status_code == 200 payload = response.json() expected = str(Path(allowed_dir).expanduser().resolve()) assert payload["allowed_local_file_dirs"] == [expected] assert Path(payload["allowed_local_file_dirs"][0]).is_absolute() def test_tool_endpoint_handles_nested_schema(): """Test that tool endpoints work with nested Pydantic models.""" class Address(BaseModel): street: str zip_code: int class NestedTool(BaseTool): address: Address def run(self) -> str: return self.address.street app = run_fastapi(tools=[NestedTool], return_app=True, app_token_env="") client = TestClient(app) response = client.post("/tool/NestedTool", json={"address": {"street": "Elm", "zip_code": 90210}}) assert response.status_code == 200 assert response.json() == {"response": "Elm"} def test_tool_endpoint_serializes_datetime_for_on_invoke(monkeypatch): """Ensure typed tool endpoints pass JSON strings to on_invoke_tool.""" class TimestampRequest(BaseModel): timestamp: datetime class TimestampFunctionTool: name = "TimestampFunctionTool" openai_schema = { "parameters": { "type": "object", "properties": { "timestamp": { "type": "string", "description": "ISO timestamp", } }, "required": ["timestamp"], } } calls: list[str] = [] @staticmethod async def on_invoke_tool(context, input_json: str): TimestampFunctionTool.calls.append(input_json) return "ok" def fake_build_request_model(*_, **__): return TimestampRequest monkeypatch.setattr(tool_endpoints, "build_request_model", fake_build_request_model) TimestampFunctionTool.calls = [] app = run_fastapi(tools=[TimestampFunctionTool], return_app=True, app_token_env="") client = TestClient(app) response = client.post("/tool/TimestampFunctionTool", json={"timestamp": "2024-05-01T09:30:00Z"}) assert response.status_code == 200 assert response.json() == {"response": "ok"} assert TimestampFunctionTool.calls, "on_invoke_tool should be triggered" payload = json.loads(TimestampFunctionTool.calls[0]) assert payload == {"timestamp": "2024-05-01T09:30:00Z"} def test_openapi_json_includes_nested_schemas(): """Verify /openapi.json contains proper schemas for tools with nested models.""" class Address(BaseModel): street: str zip_code: int class NestedTool(BaseTool): address: Address def run(self) -> str: return self.address.street class SimpleTool(BaseTool): name: str age: int def run(self) -> str: return self.name app = run_fastapi(tools=[NestedTool, SimpleTool], return_app=True, app_token_env="") client = TestClient(app) response = client.get("/openapi.json") assert response.status_code == 200 schema = response.json() assert "/tool/NestedTool" in schema["paths"] assert "/tool/SimpleTool" in schema["paths"] nested_endpoint = schema["paths"]["/tool/NestedTool"]["post"] assert "requestBody" in nested_endpoint nested_schema_ref = nested_endpoint["requestBody"]["content"]["application/json"]["schema"] assert nested_schema_ref["$ref"] == "#/components/schemas/NestedTool" assert "NestedTool" in schema["components"]["schemas"] assert "Address" in schema["components"]["schemas"] nested_tool_schema = schema["components"]["schemas"]["NestedTool"] assert nested_tool_schema["properties"]["address"]["$ref"] == "#/components/schemas/Address" address_schema = schema["components"]["schemas"]["Address"] assert address_schema["type"] == "object" assert "street" in address_schema["properties"] assert "zip_code" in address_schema["properties"] assert address_schema["required"] == ["street", "zip_code"] def test_function_tool_with_nested_schema(): """Verify that FunctionTools with nested models work correctly via adapted BaseTool.""" class Address(BaseModel): street: str zip_code: int class UserTool(BaseTool): """Create a user with address.""" name: str address: Address def run(self) -> str: return f"{self.name} at {self.address.street}" # Adapt the BaseTool to a FunctionTool (simulates what happens in agents) function_tool = ToolFactory.adapt_base_tool(UserTool) app = run_fastapi(tools=[function_tool], return_app=True, app_token_env="") client = TestClient(app) # Test that the endpoint works response = client.post( "/tool/UserTool", json={"name": "Alice", "address": {"street": "123 Main St", "zip_code": 12345}} ) assert response.status_code == 200 assert "Alice at 123 Main St" in response.json()["response"] # Test that OpenAPI schema includes nested model schema_response = client.get("/openapi.json") assert schema_response.status_code == 200 openapi_schema = schema_response.json() assert "/tool/UserTool" in openapi_schema["paths"] endpoint_schema = openapi_schema["paths"]["/tool/UserTool"]["post"] assert "requestBody" in endpoint_schema # Verify the schema is properly typed (not generic Request) request_schema = endpoint_schema["requestBody"]["content"]["application/json"]["schema"] assert "$ref" in request_schema assert "UserToolRequest" in request_schema["$ref"] def test_raw_sdk_function_tool_endpoint_receives_manual_tool_context(): """Raw SDK FunctionTools exposed directly through run_fastapi should get ToolContext.""" seen_contexts: list[ToolContext[None]] = [] @sdk_function_tool async def raw_sdk_tool(ctx: RunContextWrapper[None], value: str) -> str: tool_context = cast(ToolContext[None], ctx) seen_contexts.append(tool_context) return f"{tool_context.tool_name}:{tool_context.tool_arguments}:{value}" app = run_fastapi(tools=[raw_sdk_tool], return_app=True, app_token_env="") client = TestClient(app) response = client.post("/tool/raw_sdk_tool", json={"value": "ok"}) assert response.status_code == 200 assert response.json() == {"response": 'raw_sdk_tool:{"value":"ok"}:ok'} assert len(seen_contexts) == 1 assert seen_contexts[0].tool_name == "raw_sdk_tool" assert seen_contexts[0].tool_arguments == '{"value":"ok"}' assert seen_contexts[0].tool_call_id == "agency_swarm_manual_raw_sdk_tool" def test_strict_function_tool_rejects_extra_fields(): """Ensure strict tools exposed via FastAPI still validate unexpected inputs.""" class StrictTool(BaseTool): """Return the given value.""" class ToolConfig: strict = True value: int def run(self) -> int: return self.value strict_function_tool = ToolFactory.adapt_base_tool(StrictTool) app = run_fastapi(tools=[strict_function_tool], return_app=True, app_token_env="") client = TestClient(app) response = client.post("/tool/StrictTool", json={"value": 7, "unexpected": "boom"}) assert response.status_code == 422 detail = response.json()["detail"] assert any(item.get("type") == "extra_forbidden" for item in detail) def test_tool_endpoint_preserves_explicit_nulls(): """Tools must receive explicit null payloads without them being dropped.""" class NullableTool(BaseTool): note: str | None = None def run(self) -> str | None: return self.note app = run_fastapi(tools=[NullableTool], return_app=True, app_token_env="") client = TestClient(app) response = client.post("/tool/NullableTool", json={"note": None}) assert response.status_code == 200 assert response.json() == {"response": None} def test_function_tool_nested_list_validation_survives_schema_export(): """FunctionTools should retain nested list schemas after ToolFactory exports.""" class Address(BaseModel): street: str zip_code: int class AddressListTool(BaseTool): addresses: list[Address] def run(self) -> str: return ",".join(addr.street for addr in self.addresses) function_tool = ToolFactory.adapt_base_tool(AddressListTool) ToolFactory.get_openapi_schema([function_tool], "https://api.test.com") app = run_fastapi(tools=[function_tool], return_app=True, app_token_env="") client = TestClient(app) # Missing zip_code inside nested list should raise a FastAPI validation error (422) invalid_response = client.post("/tool/AddressListTool", json={"addresses": [{"street": "Elm"}]}) assert invalid_response.status_code == 422 valid_response = client.post( "/tool/AddressListTool", json={"addresses": [{"street": "Elm", "zip_code": 90210}]}, ) assert valid_response.status_code == 200 assert valid_response.json() == {"response": "Elm"} def test_function_tool_nested_union_falls_back_to_generic_handler(): """Nested unions should bypass the typed request model to avoid false 422 errors.""" class Contact(BaseModel): identifier: str | int class ContainerTool(BaseTool): contact: Contact def run(self) -> str: return str(self.contact.identifier) function_tool = ToolFactory.adapt_base_tool(ContainerTool) app = run_fastapi(tools=[function_tool], return_app=True, app_token_env="") client = TestClient(app) response = client.post("/tool/ContainerTool", json={"contact": {"identifier": 99}}) assert response.status_code == 200 assert response.json() == {"response": "99"} schema = client.get("/openapi.json").json() endpoint = schema["paths"]["/tool/ContainerTool"]["post"] assert "requestBody" not in endpoint def test_openapi_schema_includes_custom_server_url(): """/openapi.json should expose the configured server base URL.""" class EchoTool(BaseTool): message: str def run(self) -> str: return self.message app = run_fastapi( tools=[EchoTool], return_app=True, app_token_env="", server_url="https://api.example.com/base", ) client = TestClient(app) schema = client.get("/openapi.json").json() assert schema["servers"] == [{"url": "https://api.example.com/base"}] assert "/tool/EchoTool" in schema["paths"]