| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664 |
- """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"]
|