diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index 33a4495eb..b1146f5de 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -14,6 +14,8 @@ on: - 'src/gaia/api/**' - 'src/gaia/agents/base/**' - 'src/gaia/llm/**' + - 'hub/agents/python/routing/**' + - 'hub/agents/python/code/**' - 'tests/test_api.py' - 'setup.py' - '.github/workflows/test_api.yml' @@ -24,6 +26,8 @@ on: - 'src/gaia/api/**' - 'src/gaia/agents/base/**' - 'src/gaia/llm/**' + - 'hub/agents/python/routing/**' + - 'hub/agents/python/code/**' - 'tests/test_api.py' - 'setup.py' - '.github/workflows/test_api.yml' @@ -58,6 +62,13 @@ jobs: shell: powershell run: | uv pip install pytest pytest-timeout requests --python .venv\Scripts\python.exe + # The 'gaia-code' API model routes through RoutingAgent, which now + # ships as the standalone gaia-agent-routing wheel (#1102). Install + # both local hub packages (routing first, since gaia-agent-code + # depends on it and it isn't on PyPI) so the gaia-code streaming + # tests exercise the real agent instead of the missing-wheel error. + uv pip install -e hub/agents/python/routing --python .venv\Scripts\python.exe + uv pip install -e hub/agents/python/code --python .venv\Scripts\python.exe - name: Install Lemonade Server uses: ./.github/actions/install-lemonade diff --git a/.github/workflows/test_code_agent.yml b/.github/workflows/test_code_agent.yml index baf8bbf6d..fcda0e5ed 100644 --- a/.github/workflows/test_code_agent.yml +++ b/.github/workflows/test_code_agent.yml @@ -13,7 +13,7 @@ on: paths: - 'hub/agents/python/code/**' - 'src/gaia/agents/base/**' - - 'src/gaia/agents/routing/**' + - 'hub/agents/python/routing/**' - 'setup.py' - '.github/workflows/test_code_agent.yml' pull_request: @@ -22,7 +22,7 @@ on: paths: - 'hub/agents/python/code/**' - 'src/gaia/agents/base/**' - - 'src/gaia/agents/routing/**' + - 'hub/agents/python/routing/**' - 'setup.py' - '.github/workflows/test_code_agent.yml' merge_group: @@ -59,6 +59,10 @@ jobs: - name: Install dependencies run: | uv pip install --system -e .[dev] + # gaia-agent-code depends on gaia-agent-routing, which isn't published + # to PyPI — install the local hub package first so the dependency + # resolves locally instead of hitting the registry (#1102). + uv pip install --system -e hub/agents/python/routing # CodeAgent ships as the standalone gaia-agent-code wheel (#1397, #1102) uv pip install --system -e hub/agents/python/code # Install optional dependencies for code agent diff --git a/.github/workflows/test_docqa_agent.yml b/.github/workflows/test_docqa_agent.yml new file mode 100644 index 000000000..b93439609 --- /dev/null +++ b/.github/workflows/test_docqa_agent.yml @@ -0,0 +1,63 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +# Tests the GAIA Document Q&A agent, which ships as the standalone gaia-agent-docqa +# wheel (#1102). + +name: DocQA Agent Tests + +on: + workflow_call: + push: + branches: [ main ] + paths: + - 'hub/agents/python/docqa/**' + - 'src/gaia/agents/base/**' + - 'src/gaia/agents/tools/**' + - 'setup.py' + - '.github/workflows/test_docqa_agent.yml' + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'hub/agents/python/docqa/**' + - 'src/gaia/agents/base/**' + - 'src/gaia/agents/tools/**' + - 'setup.py' + - '.github/workflows/test_docqa_agent.yml' + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test-docqa-agent: + name: Test DocQA Agent + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install dependencies + run: | + uv pip install --system -e .[dev] + # DocumentQAAgent ships as the standalone gaia-agent-docqa wheel (#1102) + uv pip install --system -e hub/agents/python/docqa + + - name: Run DocQA Agent Tests + run: | + python -m pytest hub/agents/python/docqa/tests/ -v --tb=short diff --git a/.github/workflows/test_gaia_cli.yml b/.github/workflows/test_gaia_cli.yml index da0776d4b..33a95ca3f 100644 --- a/.github/workflows/test_gaia_cli.yml +++ b/.github/workflows/test_gaia_cli.yml @@ -94,6 +94,20 @@ jobs: uses: ./.github/workflows/test_browser_agent.yml if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') + # Test DocQA Agent (standalone hub wheel, #1102) + test-docqa-agent: + name: DocQA Agent Tests + needs: lint + uses: ./.github/workflows/test_docqa_agent.yml + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') + + # Test Routing Agent (standalone hub wheel, #1102) + test-routing-agent: + name: Routing Agent Tests + needs: lint + uses: ./.github/workflows/test_routing_agent.yml + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') + # Test Email Agent (standalone hub wheel, #1102) test-email-agent: name: Email Agent Tests @@ -112,7 +126,7 @@ jobs: test-summary: name: Test Summary runs-on: ubuntu-latest - needs: [lint, unit-tests, test-windows, test-linux, test-mcp, test-code-agent, test-chat-agent, test-connectors-demo, test-analyst-agent, test-browser-agent, test-email-agent, test-security] + needs: [lint, unit-tests, test-windows, test-linux, test-mcp, test-code-agent, test-chat-agent, test-connectors-demo, test-analyst-agent, test-browser-agent, test-docqa-agent, test-routing-agent, test-email-agent, test-security] # Run always except when workflow or any dependency is cancelled (e.g., by cancel-in-progress) if: >- ${{ always() && !cancelled() && diff --git a/.github/workflows/test_routing_agent.yml b/.github/workflows/test_routing_agent.yml new file mode 100644 index 000000000..3af4c3fb9 --- /dev/null +++ b/.github/workflows/test_routing_agent.yml @@ -0,0 +1,65 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +# Tests the GAIA Routing agent, which ships as the standalone gaia-agent-routing +# wheel (#1102). + +name: Routing Agent Tests + +on: + workflow_call: + push: + branches: [ main ] + paths: + - 'hub/agents/python/routing/**' + - 'src/gaia/agents/base/**' + - 'src/gaia/agents/registry.py' + - 'src/gaia/api/agent_registry.py' + - 'setup.py' + - '.github/workflows/test_routing_agent.yml' + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'hub/agents/python/routing/**' + - 'src/gaia/agents/base/**' + - 'src/gaia/agents/registry.py' + - 'src/gaia/api/agent_registry.py' + - 'setup.py' + - '.github/workflows/test_routing_agent.yml' + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test-routing-agent: + name: Test Routing Agent + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install dependencies + run: | + uv pip install --system -e .[dev] + # RoutingAgent ships as the standalone gaia-agent-routing wheel (#1102) + uv pip install --system -e hub/agents/python/routing + + - name: Run Routing Agent Tests + run: | + python -m pytest hub/agents/python/routing/tests/ -v --tb=short diff --git a/docs/guides/routing.mdx b/docs/guides/routing.mdx index 1b0875fe5..31b7eb921 100644 --- a/docs/guides/routing.mdx +++ b/docs/guides/routing.mdx @@ -4,7 +4,7 @@ icon: "route" --- - **Source Code:** [`src/gaia/agents/routing/agent.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/routing/agent.py) · [`src/gaia/agents/routing/system_prompt.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/routing/system_prompt.py) + **Source Code:** [`hub/agents/python/routing/gaia_agent_routing/agent.py`](https://github.com/amd/gaia/blob/main/hub/agents/python/routing/gaia_agent_routing/agent.py) · [`hub/agents/python/routing/gaia_agent_routing/system_prompt.py`](https://github.com/amd/gaia/blob/main/hub/agents/python/routing/gaia_agent_routing/system_prompt.py) ## Overview @@ -239,8 +239,8 @@ gaia-code "Create an Express API with SQLite" ## Technical Details For implementation details, see: -- Source code: `src/gaia/agents/routing/agent.py` -- System prompt: `src/gaia/agents/routing/system_prompt.py` +- Source code: `hub/agents/python/routing/gaia_agent_routing/agent.py` +- System prompt: `hub/agents/python/routing/gaia_agent_routing/system_prompt.py` - CLI integration: `src/gaia/cli.py` (search for "RoutingAgent") ## See Also diff --git a/docs/playbooks/chat-agent/part-3-deployment.mdx b/docs/playbooks/chat-agent/part-3-deployment.mdx index adf40ca36..1bfe03905 100644 --- a/docs/playbooks/chat-agent/part-3-deployment.mdx +++ b/docs/playbooks/chat-agent/part-3-deployment.mdx @@ -243,7 +243,7 @@ def _generate_search_keys(self, query: str) -> List[str]: ```python title="src/gaia/api/agent_registry.py (excerpt)" AGENT_MODELS = { "gaia-code": { - "class_name": "gaia.agents.routing.agent.RoutingAgent", + "class_name": "gaia_agent_routing.agent.RoutingAgent", "init_params": {"api_mode": True, "silent_mode": True, "max_steps": 100}, "description": "Default routing agent", }, diff --git a/docs/sdk/agents/routing.mdx b/docs/sdk/agents/routing.mdx index 624c6db71..de0e3b7c4 100644 --- a/docs/sdk/agents/routing.mdx +++ b/docs/sdk/agents/routing.mdx @@ -3,11 +3,11 @@ title: "Multi-Agent Orchestration" --- - **Source Code:** [`src/gaia/agents/routing/`](https://github.com/amd/gaia/blob/main/src/gaia/agents/routing/) + **Source Code:** [`hub/agents/python/routing/`](https://github.com/amd/gaia/blob/main/hub/agents/python/routing/) -**Import:** `from gaia.agents.routing.agent import RoutingAgent` +**Import:** `from gaia_agent_routing.agent import RoutingAgent` --- @@ -28,7 +28,7 @@ title: "Multi-Agent Orchestration" ## 11.1 Basic Routing ```python -from gaia.agents.routing.agent import RoutingAgent +from gaia_agent_routing.agent import RoutingAgent # Create router (no interactive mode - uses defaults) router = RoutingAgent(api_mode=True) @@ -53,7 +53,7 @@ print(result) ## 11.2 Interactive Routing (CLI Mode) ```python -from gaia.agents.routing.agent import RoutingAgent +from gaia_agent_routing.agent import RoutingAgent # Create router with user interaction router = RoutingAgent(api_mode=False) @@ -77,7 +77,7 @@ result = agent.process_query("Build the app") ## 11.3 Routing with Conversation History ```python -from gaia.agents.routing.agent import RoutingAgent +from gaia_agent_routing.agent import RoutingAgent router = RoutingAgent(api_mode=False) @@ -110,7 +110,7 @@ agent = router.process_query( ## 11.4 Intent Detection Patterns ```python -from gaia.agents.routing.agent import RoutingAgent +from gaia_agent_routing.agent import RoutingAgent router = RoutingAgent(api_mode=True) diff --git a/docs/sdk/infrastructure/api-server.mdx b/docs/sdk/infrastructure/api-server.mdx index 836086f88..8fa694434 100644 --- a/docs/sdk/infrastructure/api-server.mdx +++ b/docs/sdk/infrastructure/api-server.mdx @@ -87,7 +87,7 @@ model, append an entry to `AGENT_MODELS`: # src/gaia/api/agent_registry.py AGENT_MODELS = { "gaia-code": { - "class_name": "gaia.agents.routing.agent.RoutingAgent", + "class_name": "gaia_agent_routing.agent.RoutingAgent", "init_params": { "api_mode": True, "silent_mode": True, diff --git a/docs/spec/api-server.mdx b/docs/spec/api-server.mdx index 6e3831f4b..c95a7d393 100644 --- a/docs/spec/api-server.mdx +++ b/docs/spec/api-server.mdx @@ -339,7 +339,7 @@ from typing import Any, Dict AGENT_MODELS: Dict[str, Dict[str, Any]] = { "gaia-code": { - "class_name": "gaia.agents.routing.agent.RoutingAgent", + "class_name": "gaia_agent_routing.agent.RoutingAgent", "init_params": {"api_mode": True, "silent_mode": True, "streaming": False, "max_steps": 100}, "description": "Intelligent routing agent ...", diff --git a/docs/spec/routing-agent.mdx b/docs/spec/routing-agent.mdx index a62ac23f7..bf7a5a5ae 100644 --- a/docs/spec/routing-agent.mdx +++ b/docs/spec/routing-agent.mdx @@ -3,13 +3,13 @@ title: "RoutingAgent" --- - **Source Code:** [`src/gaia/agents/routing/agent.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/routing/agent.py) + **Source Code:** [`hub/agents/python/routing/gaia_agent_routing/agent.py`](https://github.com/amd/gaia/blob/main/hub/agents/python/routing/gaia_agent_routing/agent.py) **Component:** RoutingAgent - Multi-agent orchestration -**Module:** `gaia.agents.routing.agent` -**Import:** `from gaia.agents.routing.agent import RoutingAgent` +**Module:** `gaia_agent_routing.agent` +**Import:** `from gaia_agent_routing.agent import RoutingAgent` --- @@ -129,7 +129,7 @@ class RoutingAgent: ### Example 1: CLI Mode with Disambiguation ```python -from gaia.agents.routing.agent import RoutingAgent +from gaia_agent_routing.agent import RoutingAgent router = RoutingAgent() diff --git a/hub/agents/python/code/gaia_agent_code/cli.py b/hub/agents/python/code/gaia_agent_code/cli.py index 5da1c0557..7b5ac76bd 100644 --- a/hub/agents/python/code/gaia_agent_code/cli.py +++ b/hub/agents/python/code/gaia_agent_code/cli.py @@ -126,8 +126,10 @@ def cmd_run(args): return 1 try: - # Import RoutingAgent for intelligent language detection - from gaia.agents.routing.agent import RoutingAgent + # Import RoutingAgent for intelligent language detection. It ships as + # the standalone gaia-agent-routing wheel (#1102), declared as a + # dependency of this package. + from gaia_agent_routing.agent import RoutingAgent # Handle --path argument project_path = args.path if hasattr(args, "path") else None diff --git a/hub/agents/python/code/pyproject.toml b/hub/agents/python/code/pyproject.toml index e231fed2c..e2c20b2b9 100644 --- a/hub/agents/python/code/pyproject.toml +++ b/hub/agents/python/code/pyproject.toml @@ -10,7 +10,9 @@ authors = [{ name = "AMD" }] license = { text = "MIT" } readme = "README.md" requires-python = ">=3.10" -dependencies = ["amd-gaia>=0.20.0"] +# gaia-agent-routing: the `gaia-code` query path routes through RoutingAgent +# for language/project-type detection (#1102). +dependencies = ["amd-gaia>=0.20.0", "gaia-agent-routing>=0.1.0"] [project.entry-points."gaia.agent"] code = "gaia_agent_code:build_registration" diff --git a/hub/agents/python/docqa/README.md b/hub/agents/python/docqa/README.md new file mode 100644 index 000000000..772ebbd3a --- /dev/null +++ b/hub/agents/python/docqa/README.md @@ -0,0 +1,22 @@ +# gaia-agent-docqa + +Standalone GAIA agent — RAG-focused document Q&A and indexing. Depends on the published +`amd-gaia` framework wheel. + +## Install + +```bash +pip install gaia-agent-docqa # from PyPI (once published) +pip install -e hub/agents/python/docqa # editable, for development +``` + +Installing registers the `docqa` agent via the `gaia.agent` entry-point group; +the GAIA registry discovers it automatically. It is a building-block agent, +hidden from the UI selector by default. + +## Develop / test + +```bash +pip install -e ".[test]" +pytest hub/agents/python/docqa/tests/ -x +``` diff --git a/hub/agents/python/docqa/gaia-agent.yaml b/hub/agents/python/docqa/gaia-agent.yaml new file mode 100644 index 000000000..878077d89 --- /dev/null +++ b/hub/agents/python/docqa/gaia-agent.yaml @@ -0,0 +1,35 @@ +id: docqa +name: Document Q&A +version: 0.1.0 +description: "GAIA Document Q&A agent — RAG document Q&A and indexing" +author: AMD +license: MIT + +category: productivity +tags: [rag, documents, qa, retrieval] +icon: file-search +tools_count: 0 + +language: python +min_gaia_version: "0.20.0" +models: [Qwen3.5-35B-A3B-GGUF] + +python: + entry_module: gaia_agent_docqa + entry_class: DocumentQAAgent + dependencies: + - "amd-gaia>=0.20.0" + +requirements: + min_memory_gb: 8 + platforms: [win-x64, linux-x64, darwin-arm64] + +permissions: + - filesystem:read + +interfaces: + tui: false + cli: false + pipe: true + api_server: true + mcp_server: true diff --git a/hub/agents/python/docqa/gaia_agent_docqa/__init__.py b/hub/agents/python/docqa/gaia_agent_docqa/__init__.py new file mode 100644 index 000000000..db220e6fe --- /dev/null +++ b/hub/agents/python/docqa/gaia_agent_docqa/__init__.py @@ -0,0 +1,55 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""GAIA Document Q&A agent — standalone hub package. + +Registers the ``docqa`` agent (RAG document Q&A) into the GAIA registry via the +``gaia.agent`` entry-point group. It is a building-block agent, hidden from the +UI selector by default. The agent module is imported lazily so registry +discovery stays cheap. +""" + +# Re-exported lazily via ``__getattr__``; intentionally absent from ``__all__``. +__all__ = ["build_registration"] + +__version__ = "0.1.0" + +_LAZY = {"DocumentQAAgent": "agent", "DocumentQAAgentConfig": "agent"} + + +def __getattr__(name): + if name in _LAZY: + import importlib + + module = importlib.import_module(f"gaia_agent_docqa.{_LAZY[name]}") + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def build_registration(): + """Return the :class:`AgentRegistration` for the docqa agent.""" + from gaia.agents.registry import AgentRegistration, class_factory + + def factory(**kwargs): + from gaia_agent_docqa.agent import DocumentQAAgent + + return class_factory(DocumentQAAgent)(**kwargs) + + return AgentRegistration( + id="docqa", + name="Document Q&A", + description="RAG-focused agent for document Q&A and indexing", + source="installed", + conversation_starters=[ + "Index the documents in ./docs and summarize them", + "What does my report say about Q3 revenue?", + ], + factory=factory, + agent_dir=None, + models=["Qwen3.5-35B-A3B-GGUF"], + hidden=True, + namespaced_agent_id="installed:docqa", + category="productivity", + tags=["rag", "documents", "qa", "retrieval"], + icon="file-search", + tools_count=0, + ) diff --git a/src/gaia/agents/docqa/agent.py b/hub/agents/python/docqa/gaia_agent_docqa/agent.py similarity index 100% rename from src/gaia/agents/docqa/agent.py rename to hub/agents/python/docqa/gaia_agent_docqa/agent.py diff --git a/hub/agents/python/docqa/pyproject.toml b/hub/agents/python/docqa/pyproject.toml new file mode 100644 index 000000000..4f851f7fb --- /dev/null +++ b/hub/agents/python/docqa/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "gaia-agent-docqa" +version = "0.1.0" +description = "GAIA Document Q&A agent — RAG document Q&A and indexing" +authors = [{ name = "AMD" }] +license = { text = "MIT" } +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["amd-gaia>=0.20.0"] + +[project.entry-points."gaia.agent"] +docqa = "gaia_agent_docqa:build_registration" + +[project.optional-dependencies] +test = ["pytest"] + +[tool.setuptools.packages.find] +include = ["gaia_agent_docqa*"] diff --git a/tests/unit/agents/test_docqa_agent.py b/hub/agents/python/docqa/tests/test_docqa_agent.py similarity index 86% rename from tests/unit/agents/test_docqa_agent.py rename to hub/agents/python/docqa/tests/test_docqa_agent.py index 55dd2a87e..ca83babc9 100644 --- a/tests/unit/agents/test_docqa_agent.py +++ b/hub/agents/python/docqa/tests/test_docqa_agent.py @@ -1,7 +1,7 @@ # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. # SPDX-License-Identifier: MIT -"""Unit tests for DocumentQAAgent (gaia.agents.docqa). +"""Unit tests for DocumentQAAgent (gaia_agent_docqa). The agent constructs a RAGSDK at init (no network/model load at construction) and registers RAG + file tools. ``skip_lemonade=True`` is hardcoded, so no @@ -14,7 +14,7 @@ class TestDocumentQAAgentImport(unittest.TestCase): def test_can_import(self): - from gaia.agents.docqa.agent import DocumentQAAgent, DocumentQAAgentConfig + from gaia_agent_docqa.agent import DocumentQAAgent, DocumentQAAgentConfig self.assertIsNotNone(DocumentQAAgent) self.assertIsNotNone(DocumentQAAgentConfig) @@ -23,7 +23,7 @@ def test_can_import(self): class TestDocumentQAAgentConfig(unittest.TestCase): def test_defaults(self): from gaia.agents.base.agent import default_max_steps - from gaia.agents.docqa.agent import DocumentQAAgentConfig + from gaia_agent_docqa.agent import DocumentQAAgentConfig cfg = DocumentQAAgentConfig() self.assertFalse(cfg.use_claude) @@ -34,7 +34,7 @@ def test_defaults(self): class TestDocumentQAAgentInit(unittest.TestCase): def _make(self): - from gaia.agents.docqa.agent import DocumentQAAgent, DocumentQAAgentConfig + from gaia_agent_docqa.agent import DocumentQAAgent, DocumentQAAgentConfig return DocumentQAAgent(DocumentQAAgentConfig()) diff --git a/hub/agents/python/email/gaia-agent.yaml b/hub/agents/python/email/gaia-agent.yaml index c92c5e305..d7b0c7838 100644 --- a/hub/agents/python/email/gaia-agent.yaml +++ b/hub/agents/python/email/gaia-agent.yaml @@ -27,6 +27,11 @@ python: requirements: min_memory_gb: 8 + # Minimum Lemonade Server version the triage stack needs. Mirrors + # gaia_agent_email.version.MIN_LEMONADE_VERSION (the runtime source of truth + # for the GET /v1/email/init readiness check); a test keeps the two in + # lock-step. `gaia init` reads this manifest value for the install path. + min_lemonade_version: "10.2.0" platforms: [win-x64, linux-x64, darwin-arm64] interfaces: diff --git a/hub/agents/python/email/gaia_agent_email/api_routes.py b/hub/agents/python/email/gaia_agent_email/api_routes.py index 3d8e9a35c..c305bfd9d 100644 --- a/hub/agents/python/email/gaia_agent_email/api_routes.py +++ b/hub/agents/python/email/gaia_agent_email/api_routes.py @@ -48,10 +48,10 @@ import re import secrets import threading -from typing import Any, List, Literal, Optional +from typing import Any, Iterator, List, Literal, Optional, Tuple -from fastapi import APIRouter, HTTPException -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, HTTPException, Response +from fastapi.responses import HTMLResponse, StreamingResponse from gaia_agent_email.contract import ( ActionItem, DraftReply, @@ -129,6 +129,184 @@ _LEMONADE_PROBE_CONNECT_TIMEOUT = 2.0 _LEMONADE_PROBE_READ_TIMEOUT = 3.0 +# A model pull is a first-download of multi-GB weights — minutes, not seconds. +# Generous ceiling so a slow link doesn't abort a real download; the connect +# leg stays short so an unreachable server still fails fast. +_LEMONADE_PULL_TIMEOUT = (5.0, 1800.0) + + +def _resolve_probe_base(base_url: Optional[str]) -> str: + """Resolve the Lemonade ``/api/v1`` base URL for a health/model probe. + + An explicit ``base_url`` is normalised to end in ``/api/v1`` (callers often + omit it); ``None`` falls back to the env-derived default via + ``_get_lemonade_config``. Shared by the reachability probe and the + model-presence probe so both target the exact same server. + """ + from gaia.llm.lemonade_client import _get_lemonade_config + + if base_url: + probe_base = base_url.rstrip("/") + if not probe_base.endswith("/api/v1"): + probe_base = f"{probe_base}/api/v1" + return probe_base + _, _, probe_base = _get_lemonade_config() + return probe_base + + +def _probe_lemonade_health( + base_url: Optional[str] = None, +) -> Tuple[bool, str, Optional[str]]: + """Probe Lemonade's ``/health`` with a short connect timeout (#1677). + + Returns ``(reachable, probe_base, version)``. Any HTTP response — even an + error status — means the server is up; only a connection/timeout failure + counts as unreachable (auth/model errors surface later on the real chat + call, where their messages are specific). ``version`` is Lemonade's + self-reported server version from the ``/health`` body (``None`` when the + server doesn't advertise one or the body isn't JSON). Never raises: the + readiness endpoint reports the values rather than failing. + """ + import requests + + probe_base = _resolve_probe_base(base_url) + try: + resp = requests.get( + f"{probe_base}/health", + timeout=(_LEMONADE_PROBE_CONNECT_TIMEOUT, _LEMONADE_PROBE_READ_TIMEOUT), + ) + except requests.exceptions.RequestException: + return False, probe_base, None + + version: Optional[str] = None + try: + body = resp.json() + if isinstance(body, dict): + version = body.get("version") + except ValueError: + version = None + return True, probe_base, version + + +def _probe_lemonade_reachable(base_url: Optional[str] = None) -> Tuple[bool, str]: + """Reachability-only view of :func:`_probe_lemonade_health`. + + Kept for callers (the POST provisioning verb) that need only "is it up?", + not the version — so they share the single ``/health`` probe logic. + """ + reachable, probe_base, _version = _probe_lemonade_health(base_url) + return reachable, probe_base + + +def _probe_model_present(probe_base: str, model_id: str) -> bool: + """Cheaply check whether ``model_id`` is downloaded on the Lemonade server. + + Queries the model list (downloaded models only) with the same short + timeout as the reachability probe and matches on the ``id`` field. Sends + the resolved Lemonade auth header so an authenticated server answers + instead of 401-ing. Raises ``requests.RequestException`` on a transport + failure — the caller turns that into an actionable readiness hint rather + than silently reporting "absent". + + "Present" is intentionally cheap (a list lookup, no model load). Whether + the model actually *loads* (``loadable``) is not probed in v1 — forcing a + load is heavy, so the readiness response reports ``loadable=null``. + """ + import requests + + from gaia.llm.lemonade_client import ( + lemonade_auth_headers, + resolve_lemonade_api_key, + ) + + resp = requests.get( + f"{probe_base}/models", + headers=lemonade_auth_headers(resolve_lemonade_api_key()), + timeout=(_LEMONADE_PROBE_CONNECT_TIMEOUT, _LEMONADE_PROBE_READ_TIMEOUT), + ) + resp.raise_for_status() + payload = resp.json() + data = payload.get("data", []) if isinstance(payload, dict) else [] + return any(isinstance(m, dict) and m.get("id") == model_id for m in data) + + +def _pull_model(probe_base: str, model_id: str) -> None: + """Tell a RUNNING Lemonade Server to download ``model_id``. + + Posts to Lemonade's ``/pull`` with ONLY ``model_name`` — the email triage + model is a *built-in* Lemonade model, and sending ``recipe`` for a built-in + makes Lemonade 400 (the #1655 trap). Sends the resolved auth header so an + authenticated server accepts the request. Raises ``requests.RequestException`` + (incl. ``HTTPError`` on a non-2xx) on failure — the caller surfaces it as a + loud provisioning-failed line rather than swallowing it. + + This is the ONLY provisioning the frozen sidecar can do: it cannot run the + full ``gaia init`` (no bundled CLI/installer) and cannot install Lemonade + itself if Lemonade is what's missing (chicken-and-egg). + """ + import requests + + from gaia.llm.lemonade_client import ( + lemonade_auth_headers, + resolve_lemonade_api_key, + ) + + resp = requests.post( + f"{probe_base}/pull", + json={"model_name": model_id}, + headers=lemonade_auth_headers(resolve_lemonade_api_key()), + timeout=_LEMONADE_PULL_TIMEOUT, + ) + resp.raise_for_status() + + +def _resolve_email_model_id() -> str: + """Resolve the Lemonade model id the email agent triages with. + + Mirrors the agent's own resolution (``config.model_id or + DEFAULT_MODEL_NAME``) so the readiness probe reports the exact model the + triage path will load. + """ + from gaia_agent_email.config import EmailAgentConfig + + from gaia.llm.lemonade_client import DEFAULT_MODEL_NAME + + return EmailAgentConfig().model_id or DEFAULT_MODEL_NAME + + +def _parse_version(version: Optional[str]) -> Optional[Tuple[int, ...]]: + """Parse a dotted version string into a comparable int tuple. + + Mirrors ``gaia.installer.init_command.InitCommand._parse_version`` (same + semantics: strip a leading ``v``, take the first three dotted parts as + ints). Kept LOCAL rather than imported because the frozen sidecar does not + bundle ``gaia.installer`` — importing it at runtime would ``ModuleNotFound`` + in the binary this endpoint exists to serve. Returns ``None`` when the + string is missing or unparseable. + """ + if not version: + return None + try: + return tuple(int(p) for p in version.lstrip("v").split(".")[:3]) + except (ValueError, IndexError, AttributeError): + return None + + +def _version_meets_min(found: Optional[str], minimum: str) -> Optional[bool]: + """Return whether ``found`` >= ``minimum`` (both dotted versions). + + ``None`` means "can't tell" — the server didn't advertise a version or it + was unparseable. Readiness treats unknown as non-blocking (it does not + fabricate a pass/fail), mirroring ``gaia init``'s "don't block on an + unparseable version" policy, but surfaces ``compatible=null`` so a consumer + can see the check was indeterminate. + """ + found_t = _parse_version(found) + min_t = _parse_version(minimum) + if found_t is None or min_t is None: + return None + return found_t >= min_t + def _split_sentences(text: str) -> List[str]: text = re.sub(r"\s+", " ", (text or "").strip()) @@ -257,14 +435,8 @@ def _assert_lemonade_reachable(self, base_url: Optional[str]) -> None: real chat call, where their messages are specific). """ import requests - from gaia.llm.lemonade_client import _get_lemonade_config - if base_url: - probe_base = base_url.rstrip("/") - if not probe_base.endswith("/api/v1"): - probe_base = f"{probe_base}/api/v1" - else: - _, _, probe_base = _get_lemonade_config() + probe_base = _resolve_probe_base(base_url) health_url = f"{probe_base}/health" try: @@ -800,6 +972,78 @@ class VersionResponse(_Strict): agentVersion: str = Field(..., description="Package build version.") +class InitLemonadeStatus(_Strict): + """Reachability AND version-compatibility of the local Lemonade Server.""" + + reachable: bool = Field( + ..., description="True when Lemonade answered the /health probe." + ) + base_url: str = Field(..., description="The /api/v1 base URL that was probed.") + version: Optional[str] = Field( + default=None, + description=( + "Lemonade's self-reported server version (from /health). Null when " + "the server doesn't advertise one." + ), + ) + min_version: str = Field( + ..., + description=( + "Minimum Lemonade version the triage stack requires " + "(gaia_agent_email.version.MIN_LEMONADE_VERSION)." + ), + ) + compatible: Optional[bool] = Field( + default=None, + description=( + "True when version >= min_version. Null when the version could not " + "be determined (the check was indeterminate, not a pass)." + ), + ) + + +class InitModelStatus(_Strict): + """Presence (and, when cheap, loadability) of the triage model.""" + + id: str = Field(..., description="Resolved Lemonade model id for triage.") + present: bool = Field( + ..., description="True when the model is downloaded on the server." + ) + loadable: Optional[bool] = Field( + default=None, + description=( + "Whether the model actually loads. Not probed in v1 (forcing a " + "load is heavy), so this is null — `present` is the readiness " + "signal. Reserved for an opt-in deeper check." + ), + ) + + +class InitResponse(_Strict): + """Readiness preflight for the whole triage stack (#1795). + + ``ready`` is True only when Lemonade is reachable AND the triage model is + present. The route returns HTTP 200 when ready and 503 when not, with an + actionable ``hint`` naming what to fix — so an integrator can verify "ready + to triage," not just "process up." Read-only: probes only, no model pull. + """ + + ready: bool = Field( + ..., description="True when the triage stack is ready to serve." + ) + lemonade: InitLemonadeStatus = Field( + ..., description="Lemonade Server reachability." + ) + model: InitModelStatus = Field(..., description="Triage model status.") + hint: Optional[str] = Field( + default=None, + description=( + "Actionable next step when not ready (what failed / what to do). " + "Null when ready." + ), + ) + + class EmailDraftRequest(_Strict): """Propose a reply and obtain a confirmation token for it.""" @@ -982,6 +1226,240 @@ async def email_version() -> VersionResponse: return VersionResponse(apiVersion=API_VERSION, agentVersion=AGENT_VERSION) +def _compute_init_status(base_url: Optional[str] = None) -> InitResponse: + """Probe the triage stack and return its structured readiness status. + + Read-only: checks (1) Lemonade reachable AND at a compatible version + (>= ``MIN_LEMONADE_VERSION``), then (2) the triage model is downloaded. + Never pulls a model. Returns a not-ready ``InitResponse`` with an actionable + ``hint`` for each failure mode rather than raising, so the route can + serialize the same body under both 200 and 503. + + ``ready`` requires reachable + model present + version not-too-old. An + indeterminate version (server didn't advertise one) is reported as + ``compatible=null`` and does NOT block — mirroring ``gaia init``'s policy of + not failing on an unparseable version. + """ + import requests + from gaia_agent_email.version import MIN_LEMONADE_VERSION + + model_id = _resolve_email_model_id() + reachable, probe_base, version = _probe_lemonade_health(base_url) + compatible = ( + _version_meets_min(version, MIN_LEMONADE_VERSION) if reachable else None + ) + lemonade = InitLemonadeStatus( + reachable=reachable, + base_url=probe_base, + version=version, + min_version=MIN_LEMONADE_VERSION, + compatible=compatible, + ) + + if not reachable: + return InitResponse( + ready=False, + lemonade=lemonade, + model=InitModelStatus(id=model_id, present=False, loadable=None), + hint=( + f"Local Lemonade Server is not reachable at {probe_base} — start " + "it with `lemonade-server serve` (or run `gaia init`), then retry." + ), + ) + + try: + present = _probe_model_present(probe_base, model_id) + except requests.exceptions.RequestException as exc: + # Lemonade answered /health but its model list could not be read. + # Surface loudly (still 503) rather than silently reporting "absent". + return InitResponse( + ready=False, + lemonade=lemonade, + model=InitModelStatus(id=model_id, present=False, loadable=None), + hint=( + f"Lemonade is reachable but its model list at {probe_base}/models " + f"could not be read ({type(exc).__name__}: {exc}). Make sure the " + "server is healthy, then retry." + ), + ) + + model = InitModelStatus(id=model_id, present=present, loadable=None) + + # Version too old is the most fundamental blocker — surface it before the + # model hint (upgrading Lemonade comes first even if the model is missing). + if compatible is False: + return InitResponse( + ready=False, + lemonade=lemonade, + model=model, + hint=( + f"Lemonade {version} is older than the required " + f"{MIN_LEMONADE_VERSION} — upgrade it (see " + "https://lemonade-server.ai or run `gaia init`), then retry." + ), + ) + + if not present: + return InitResponse( + ready=False, + lemonade=lemonade, + model=model, + hint=( + f"Model `{model_id}` not downloaded — run `gaia init` (or pull it " + "via Lemonade), then retry." + ), + ) + + return InitResponse( + ready=True, + lemonade=lemonade, + model=model, + hint=None, + ) + + +@router.get( + "/init", + response_model=InitResponse, + responses={503: {"model": InitResponse}}, +) +async def email_init(response: Response) -> InitResponse: + """Readiness preflight: validate the whole triage stack (#1795). + + Returns HTTP 200 when ready, 503 when not (with an actionable ``hint``). + Unlike ``/health`` (liveness-only — never touches the LLM), this probes the + local Lemonade Server, checks it is at a compatible VERSION (>= the agent's + ``MIN_LEMONADE_VERSION``), and confirms the triage model is downloaded — so a + host can verify "ready to triage," not just "process up." Read-only — no + model pull or provisioning is triggered. + """ + status = await asyncio.to_thread(_compute_init_status) + if not status.ready: + response.status_code = 503 + return status + + +def _provision_progress(probe_base: str, model_id: str) -> Iterator[str]: + """Yield newline-terminated progress lines while provisioning the model. + + The only realistic sidecar provisioning action: ask the already-running + local Lemonade to download the configured email model, narrating each step + so a consumer can render it terminal-style. The leading reachability check + is handled by the route (so it can return a real 503); this generator runs + only once Lemonade is confirmed up. + + HTTP note: once a streamed 200 is committed the status can no longer change, + so the FINAL line is the authoritative success/failure signal — a line + starting ``✓`` for success, ``✗`` for failure. + """ + import requests + + def line(text: str) -> str: + return text + "\n" + + yield line(f"→ Email triage model: {model_id}") + yield line(f"→ Lemonade reachable at {probe_base}") + yield line(f"→ Checking whether {model_id} is already downloaded…") + + try: + present = _probe_model_present(probe_base, model_id) + except requests.exceptions.RequestException as exc: + yield line( + f"✗ Could not read Lemonade's model list ({type(exc).__name__}: {exc})." + ) + yield line( + "✗ Provisioning aborted — make sure the Lemonade Server is healthy, then retry." + ) + return + + if present: + yield line(f"✓ {model_id} is already downloaded — nothing to pull.") + yield line( + "✓ Provisioning complete. Re-run GET /v1/email/init to confirm readiness." + ) + return + + yield line( + f"→ Pulling {model_id} via Lemonade — first download can take several " + "minutes…" + ) + try: + _pull_model(probe_base, model_id) + except requests.exceptions.RequestException as exc: + yield line(f"✗ Provisioning failed: {type(exc).__name__}: {exc}") + yield line( + "✗ The model was not downloaded. Check the Lemonade Server logs, then retry." + ) + return + + yield line(f"✓ {model_id} downloaded.") + + # Verify the pull actually registered the model. A verify hiccup is surfaced + # (not swallowed) but does not by itself fail a pull Lemonade reported OK. + try: + if _probe_model_present(probe_base, model_id): + yield line(f"✓ Verified {model_id} is registered with Lemonade.") + else: + yield line( + f"✗ {model_id} is still not listed after the pull — provisioning " + "incomplete. Check the Lemonade Server logs, then retry." + ) + return + except requests.exceptions.RequestException as exc: + yield line( + f"⚠ Pull reported success but the model list could not be re-read " + f"({type(exc).__name__}). Re-run GET /v1/email/init to confirm." + ) + + yield line( + "✓ Provisioning complete. Re-run GET /v1/email/init to confirm readiness." + ) + + +@router.post("/init", include_in_schema=False) +async def email_provision() -> StreamingResponse: + """Provision the triage stack and STREAM terminal-style progress (#1795). + + The companion verb to ``GET /v1/email/init`` (readiness probe). It tells a + RUNNING local Lemonade to download the configured email model and streams + newline-delimited progress (``text/plain``) so a consumer can show what is + happening line by line. + + Scope (frozen-binary reality): the sidecar cannot run the full ``gaia init`` + or install Lemonade itself — if Lemonade is unreachable this returns a real + **503** with an actionable line and pulls nothing. Once a pull starts the + response is a committed **200**; the final ``✓``/``✗`` line is then the + authoritative outcome (the HTTP status can't change mid-stream). + + Not in the OpenAPI JSON contract — it's a streaming operational verb, like + ``GET /spec`` — so it's documented in the HTML spec instead. + """ + media_type = "text/plain; charset=utf-8" + model_id = _resolve_email_model_id() + reachable, probe_base = await asyncio.to_thread(_probe_lemonade_reachable) + + if not reachable: + # Fail loudly BEFORE streaming so the status code is a truthful 503. + def _unreachable() -> Iterator[str]: + yield f"✗ Local Lemonade Server is not reachable at {probe_base}.\n" + yield ( + "✗ Start it with `lemonade-server serve` (or run `gaia init`), " + "then POST /v1/email/init again.\n" + ) + yield ( + "✗ The sidecar can't install Lemonade itself — that's a host " + "prerequisite.\n" + ) + + return StreamingResponse(_unreachable(), media_type=media_type, status_code=503) + + return StreamingResponse( + _provision_progress(probe_base, model_id), + media_type=media_type, + status_code=200, + ) + + @router.get("/spec", response_class=HTMLResponse, include_in_schema=False) async def email_spec() -> HTMLResponse: """Serve the self-contained HTML endpoint spec page. @@ -1038,6 +1516,9 @@ async def email_playground() -> HTMLResponse: "EmailSendResponse", "HealthResponse", "VersionResponse", + "InitResponse", + "InitLemonadeStatus", + "InitModelStatus", # Shared formatting helpers reused by the MCP surface. "_format_address", "_payload_fingerprint", diff --git a/hub/agents/python/email/gaia_agent_email/spec_html.py b/hub/agents/python/email/gaia_agent_email/spec_html.py index beb5bd435..44f6569d2 100644 --- a/hub/agents/python/email/gaia_agent_email/spec_html.py +++ b/hub/agents/python/email/gaia_agent_email/spec_html.py @@ -18,8 +18,6 @@ from pathlib import Path from typing import Any, List, Optional, Tuple, Type, Union, get_args, get_origin -from pydantic import BaseModel - from gaia_agent_email.contract import ( SCHEMA_VERSION, ActionItem, @@ -33,6 +31,7 @@ SingleEmailInput, ThreadInput, ) +from pydantic import BaseModel # --------------------------------------------------------------------------- # Internal helpers @@ -362,6 +361,9 @@ def render_endpoint_spec_html() -> str: EmailDraftResponse, EmailSendRequest, EmailSendResponse, + InitLemonadeStatus, + InitModelStatus, + InitResponse, ) draft_block = _endpoint_block( @@ -388,6 +390,52 @@ def render_endpoint_spec_html() -> str: response_sections=[("EmailSendResponse", EmailSendResponse)], ) + # Readiness preflight (#1795). GET, response-only — documents the + # structured status a host polls before triaging. Derived from the live + # route models so the table cannot drift from what the endpoint returns. + init_block = ( + f'
' + f'GET' + f'/v1/email/init' + f'

Readiness preflight for the whole triage stack. ' + f"Returns HTTP 200 when ready and 503 when not, with an actionable " + f"hint. Unlike /health (liveness-only), this " + f"probes the local Lemonade Server, checks it is at a compatible " + f"version (≥ min_version), and confirms " + f"the triage model is downloaded — so a host can verify “ready to " + f"triage,” not just “process up.” Read-only: probes " + f"only, no model pull.

" + f"

Response body

" + f"{_model_table(InitResponse, 'InitResponse')}" + f"{_model_table(InitLemonadeStatus, 'InitLemonadeStatus')}" + f"{_model_table(InitModelStatus, 'InitModelStatus')}" + f"
" + ) + + # Provisioning verb (#1795 follow-up). POST on the same path, but it STREAMS + # terminal-style progress instead of returning JSON — so it is documented + # here rather than in the JSON OpenAPI contract. + provision_block = ( + f'
' + f'POST' + f'/v1/email/init' + f'

Provision the triage stack and stream ' + f"terminal-style progress. Tells the running local Lemonade " + f"Server to download the configured email model, emitting " + f"newline-delimited progress lines (text/plain) a consumer " + f"can render line by line. A line beginning marks " + f"success, a failure; the final line is authoritative.

" + f'

Scope: the sidecar cannot run the full ' + f"gaia init or install Lemonade itself. If Lemonade is " + f"unreachable this returns 503 with an actionable line " + f"and pulls nothing. Once a pull starts the response is a committed " + f"200 (HTTP status cannot change mid-stream), so the " + f"trailing / line carries the real outcome. " + f"On success, re-run GET /v1/email/init to confirm " + f"readiness.

" + f"
" + ) + body = f""" @@ -417,6 +465,10 @@ def render_endpoint_spec_html() -> str: {send_block} +{init_block} + +{provision_block} +

Convenience pages

diff --git a/hub/agents/python/email/gaia_agent_email/version.py b/hub/agents/python/email/gaia_agent_email/version.py index 649689888..18f34d79a 100644 --- a/hub/agents/python/email/gaia_agent_email/version.py +++ b/hub/agents/python/email/gaia_agent_email/version.py @@ -34,4 +34,15 @@ # SCHEMA_VERSION so a contract bump is an API bump — no second number to forget. API_VERSION = SCHEMA_VERSION -__all__ = ["AGENT_VERSION", "API_VERSION"] +# Minimum Lemonade Server version the triage stack requires (#1795 readiness). +# This is the RUNTIME source of truth for the GET /v1/email/init version check: +# the frozen sidecar bundles this module but NOT ``gaia.installer`` or the +# ``gaia-agent.yaml`` manifest, so the value cannot be read from either at run +# time. ``gaia-agent.yaml``'s ``requirements.min_lemonade_version`` mirrors this +# literal for ``gaia init`` / manifest tooling, and +# ``test_init_endpoint.py::test_min_lemonade_version_locksteps_with_manifest`` +# fails if the two drift — keep them in lock-step when bumping (mirrors the +# INIT_PROFILES lock-step note in ``gaia.llm.lemonade_client``). +MIN_LEMONADE_VERSION = "10.2.0" + +__all__ = ["AGENT_VERSION", "API_VERSION", "MIN_LEMONADE_VERSION"] diff --git a/hub/agents/python/email/openapi.email.json b/hub/agents/python/email/openapi.email.json index b4256c724..5024174eb 100644 --- a/hub/agents/python/email/openapi.email.json +++ b/hub/agents/python/email/openapi.email.json @@ -560,6 +560,130 @@ "title": "HealthResponse", "type": "object" }, + "InitLemonadeStatus": { + "additionalProperties": false, + "description": "Reachability AND version-compatibility of the local Lemonade Server.", + "properties": { + "base_url": { + "description": "The /api/v1 base URL that was probed.", + "title": "Base Url", + "type": "string" + }, + "compatible": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "True when version >= min_version. Null when the version could not be determined (the check was indeterminate, not a pass).", + "title": "Compatible" + }, + "min_version": { + "description": "Minimum Lemonade version the triage stack requires (gaia_agent_email.version.MIN_LEMONADE_VERSION).", + "title": "Min Version", + "type": "string" + }, + "reachable": { + "description": "True when Lemonade answered the /health probe.", + "title": "Reachable", + "type": "boolean" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Lemonade's self-reported server version (from /health). Null when the server doesn't advertise one.", + "title": "Version" + } + }, + "required": [ + "reachable", + "base_url", + "min_version" + ], + "title": "InitLemonadeStatus", + "type": "object" + }, + "InitModelStatus": { + "additionalProperties": false, + "description": "Presence (and, when cheap, loadability) of the triage model.", + "properties": { + "id": { + "description": "Resolved Lemonade model id for triage.", + "title": "Id", + "type": "string" + }, + "loadable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether the model actually loads. Not probed in v1 (forcing a load is heavy), so this is null — `present` is the readiness signal. Reserved for an opt-in deeper check.", + "title": "Loadable" + }, + "present": { + "description": "True when the model is downloaded on the server.", + "title": "Present", + "type": "boolean" + } + }, + "required": [ + "id", + "present" + ], + "title": "InitModelStatus", + "type": "object" + }, + "InitResponse": { + "additionalProperties": false, + "description": "Readiness preflight for the whole triage stack (#1795).\n\n``ready`` is True only when Lemonade is reachable AND the triage model is\npresent. The route returns HTTP 200 when ready and 503 when not, with an\nactionable ``hint`` naming what to fix — so an integrator can verify \"ready\nto triage,\" not just \"process up.\" Read-only: probes only, no model pull.", + "properties": { + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actionable next step when not ready (what failed / what to do). Null when ready.", + "title": "Hint" + }, + "lemonade": { + "$ref": "#/components/schemas/InitLemonadeStatus", + "description": "Lemonade Server reachability." + }, + "model": { + "$ref": "#/components/schemas/InitModelStatus", + "description": "Triage model status." + }, + "ready": { + "description": "True when the triage stack is ready to serve.", + "title": "Ready", + "type": "boolean" + } + }, + "required": [ + "ready", + "lemonade", + "model" + ], + "title": "InitResponse", + "type": "object" + }, "SingleEmailInput": { "additionalProperties": false, "description": "A single email to triage (AC3 — single-email path).", @@ -838,6 +962,38 @@ ] } }, + "/v1/email/init": { + "get": { + "description": "Readiness preflight: validate the whole triage stack (#1795).\n\nReturns HTTP 200 when ready, 503 when not (with an actionable ``hint``).\nUnlike ``/health`` (liveness-only — never touches the LLM), this probes the\nlocal Lemonade Server, checks it is at a compatible VERSION (>= the agent's\n``MIN_LEMONADE_VERSION``), and confirms the triage model is downloaded — so a\nhost can verify \"ready to triage,\" not just \"process up.\" Read-only — no\nmodel pull or provisioning is triggered.", + "operationId": "email_init_v1_email_init_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitResponse" + } + } + }, + "description": "Successful Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Email Init", + "tags": [ + "email" + ] + } + }, "/v1/email/send": { "post": { "description": "Send a reply — gated on explicit confirmation (#1264).\n\nThe confirmation gate is enforced FIRST: a request without a valid,\npayload-bound confirmation token is rejected with HTTP 403 before any\nbackend call (or even backend resolution). This mirrors the agent's\n``TOOLS_REQUIRING_CONFIRMATION`` guard, translated to the API boundary,\nand guarantees the gate fires regardless of backend health. Never\nauto-confirms.", diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html index dfa2009f5..a378aae9b 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -1292,6 +1292,24 @@

REST surface & contract

Endpoints

+
+ GET/v1/email/init +

Readiness preflight for the whole triage stack (#1795). Returns 200 when ready and 503 when not, with an actionable hint. Unlike /health (liveness-only — never touches the LLM), this probes the local Lemonade Server, checks it is at a compatible version (≥ the agent's min_lemonade_version), and confirms the triage model is downloaded, so a host can verify “ready to triage,” not just “process up.” Read-only — probes only, no model pull or provisioning is triggered.

+

Response body — InitResponse

+ + + + + +
FieldTypeDescription
readyboolTrue only when Lemonade is reachable, at a compatible version, and the triage model is present.
lemonadeobject{ reachable, base_url, version, min_version, compatible }. version is Lemonade's self-reported version (null if not advertised); compatible is version ≥ min_version (null = indeterminate, not a pass).
modelobject{ id: string, present: bool, loadable: bool|null }. present is a cheap model-list lookup; loadable is null in v1 (forcing a load is heavy).
hintstring | nullActionable next step when not ready (e.g. “Lemonade x.y.z < required a.b.c — upgrade”); null when ready.
+
+ +
+ POST/v1/email/init +

Provision the triage stack and stream terminal-style progress (#1795). Tells the running local Lemonade Server to download the configured email model, streaming newline-delimited progress lines (text/plain) a consumer can render line by line. A line beginning marks success, a failure; the final line is authoritative.

+

Scope (frozen-binary reality): the sidecar can’t run the full gaia init or install Lemonade itself (chicken-and-egg). If Lemonade is unreachable it returns 503 with an actionable line and pulls nothing. Once a pull starts the response is a committed 200 — the HTTP status can’t change mid-stream, so the trailing / line carries the real outcome. On success, re-run GET /v1/email/init to confirm readiness.

+
+
POST/v1/email/triage

Triage a single email or a full thread. Returns category, spam/phishing signals, summary, action items, and an optional draft proposal. Reads/sends no mail — analyzes only the payload.

@@ -1357,7 +1375,7 @@

Errors & status codes

403Forbidden — send gate/v1/email/send called without a valid, payload-bound confirmation_token. Mint one with /v1/email/draft. 422Validation errorRequest violates the contract — unknown field, missing required field, or malformed address. The contract is strict (extra fields are rejected). 502Upstream error/triage: the local-LLM triage call failed. /send: the mail backend accepted the send but returned no message id. - 503Backend unavailableThe mailbox account isn't connected. Connect the provider before sending. + 503Backend unavailable / not ready/send: the mailbox account isn't connected — connect the provider first. /v1/email/init: the triage stack isn't ready (Lemonade unreachable or the model not downloaded); the hint names the fix. diff --git a/hub/agents/python/email/tests/test_rest_contract.py b/hub/agents/python/email/tests/test_rest_contract.py index 8c50f9896..910bef9aa 100644 --- a/hub/agents/python/email/tests/test_rest_contract.py +++ b/hub/agents/python/email/tests/test_rest_contract.py @@ -47,6 +47,7 @@ ("post", "/v1/email/send"): "EmailSendResponse", ("get", "/v1/email/health"): "HealthResponse", ("get", "/v1/email/version"): "VersionResponse", + ("get", "/v1/email/init"): "InitResponse", } diff --git a/hub/agents/python/routing/README.md b/hub/agents/python/routing/README.md new file mode 100644 index 000000000..e0d466620 --- /dev/null +++ b/hub/agents/python/routing/README.md @@ -0,0 +1,30 @@ +# gaia-agent-routing + +Standalone GAIA agent — the **routing meta-agent**. `RoutingAgent` analyzes a +user request, disambiguates language/project-type, and routes the work to the +right concrete agent (currently `CodeAgent`). Depends on the published +`amd-gaia` framework wheel. + +This is **infrastructure**, not a user-selectable agent: it does not inherit the +base `Agent` and is loaded by class path from the OpenAI-compatible API server +(`gaia.api.agent_registry`, model `gaia-code`). It therefore ships **without** a +`gaia.agent` entry point — installing the wheel just makes `gaia_agent_routing` +importable so the API server can resolve it. + +## Install + +```bash +pip install gaia-agent-routing # from PyPI (once published) +pip install -e hub/agents/python/routing # editable, for development +``` + +The API server's `gaia-code` model routes through `RoutingAgent`, which in turn +needs the `gaia-agent-code` wheel installed. Install both (or +`pip install amd-gaia[agents]`) to use `gaia api` for code generation. + +## Develop / test + +```bash +pip install -e ".[test]" +pytest hub/agents/python/routing/tests/ -x +``` diff --git a/hub/agents/python/routing/gaia-agent.yaml b/hub/agents/python/routing/gaia-agent.yaml new file mode 100644 index 000000000..874b465da --- /dev/null +++ b/hub/agents/python/routing/gaia-agent.yaml @@ -0,0 +1,35 @@ +id: routing +name: Routing Agent +version: 0.1.0 +description: "GAIA routing agent — meta-agent that routes requests to the right concrete agent" +author: AMD +license: MIT + +category: infrastructure +tags: [routing, meta, infrastructure] +icon: route +tools_count: 0 + +language: python +min_gaia_version: "0.20.0" +models: [Qwen3.5-35B-A3B-GGUF] + +python: + entry_module: gaia_agent_routing + entry_class: RoutingAgent + dependencies: + - "amd-gaia>=0.20.0" + +requirements: + min_memory_gb: 8 + platforms: [win-x64, linux-x64, darwin-arm64] + +# RoutingAgent is infrastructure, not a user-selectable registry agent: it is +# loaded by class path from gaia.api.agent_registry (the OpenAI-compatible API +# server) and has no `gaia.agent` entry point. +interfaces: + tui: false + cli: false + pipe: false + api_server: true + mcp_server: false diff --git a/hub/agents/python/routing/gaia_agent_routing/__init__.py b/hub/agents/python/routing/gaia_agent_routing/__init__.py new file mode 100644 index 000000000..1279949a0 --- /dev/null +++ b/hub/agents/python/routing/gaia_agent_routing/__init__.py @@ -0,0 +1,17 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""GAIA Routing agent — standalone hub package. + +``RoutingAgent`` is GAIA infrastructure: a meta-agent that analyzes a request +and routes it to the right concrete agent (currently CodeAgent). It is NOT a +GAIA registry agent — it does not inherit the base ``Agent`` and is loaded by +class path from the OpenAI-compatible API server +(``gaia.api.agent_registry``). It therefore ships *without* a ``gaia.agent`` +entry point; installing this wheel simply makes ``gaia_agent_routing`` importable. +""" + +from .agent import RoutingAgent + +__all__ = ["RoutingAgent"] + +__version__ = "0.1.0" diff --git a/src/gaia/agents/routing/agent.py b/hub/agents/python/routing/gaia_agent_routing/agent.py similarity index 100% rename from src/gaia/agents/routing/agent.py rename to hub/agents/python/routing/gaia_agent_routing/agent.py diff --git a/src/gaia/agents/routing/system_prompt.py b/hub/agents/python/routing/gaia_agent_routing/system_prompt.py similarity index 100% rename from src/gaia/agents/routing/system_prompt.py rename to hub/agents/python/routing/gaia_agent_routing/system_prompt.py diff --git a/hub/agents/python/routing/pyproject.toml b/hub/agents/python/routing/pyproject.toml new file mode 100644 index 000000000..9c45fe4a6 --- /dev/null +++ b/hub/agents/python/routing/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "gaia-agent-routing" +version = "0.1.0" +description = "GAIA Routing agent — meta-agent that routes requests to the right concrete agent" +authors = [{ name = "AMD" }] +license = { text = "MIT" } +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["amd-gaia>=0.20.0"] + +# NOTE: no `gaia.agent` entry point. RoutingAgent is infrastructure loaded by +# class path from gaia.api.agent_registry, not a GAIA registry agent. + +[project.optional-dependencies] +test = ["pytest"] + +[tool.setuptools.packages.find] +include = ["gaia_agent_routing*"] diff --git a/tests/unit/agents/test_routing_agent.py b/hub/agents/python/routing/tests/test_routing_agent.py similarity index 97% rename from tests/unit/agents/test_routing_agent.py rename to hub/agents/python/routing/tests/test_routing_agent.py index 50f56498d..a92263153 100644 --- a/tests/unit/agents/test_routing_agent.py +++ b/hub/agents/python/routing/tests/test_routing_agent.py @@ -30,7 +30,7 @@ def mock_llm_client(): @pytest.fixture() def _patch_create_client(mock_llm_client): """Patch create_client so RoutingAgent.__init__ uses the mock LLM.""" - with patch("gaia.agents.routing.agent.create_client", return_value=mock_llm_client): + with patch("gaia_agent_routing.agent.create_client", return_value=mock_llm_client): yield @@ -54,7 +54,7 @@ def _patch_code_agent(): @pytest.fixture() def router(_patch_create_client): """Return a RoutingAgent wired to the mock LLM.""" - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent return RoutingAgent(api_mode=True) @@ -68,7 +68,7 @@ class TestRoutingAgentInit: """Constructor and configuration.""" def test_import_and_exposes_process_query(self): - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent assert hasattr(RoutingAgent, "process_query") @@ -77,25 +77,25 @@ def test_default_routing_model(self, router): def test_custom_routing_model_via_env(self, _patch_create_client, monkeypatch): monkeypatch.setenv("AGENT_ROUTING_MODEL", "custom-model") - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent r = RoutingAgent(api_mode=True) assert r.routing_model == "custom-model" def test_api_mode_stored(self, _patch_create_client): - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent r = RoutingAgent(api_mode=True) assert r.api_mode is True def test_cli_mode_default(self, _patch_create_client): - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent r = RoutingAgent() assert r.api_mode is False def test_agent_kwargs_stored(self, _patch_create_client): - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent r = RoutingAgent(api_mode=True, foo="bar") assert r.agent_kwargs["foo"] == "bar" @@ -445,7 +445,7 @@ class TestProcessQueryCLIMode: def test_cli_mode_asks_clarification_then_resolves( self, _patch_create_client, mock_llm_client, _patch_code_agent ): - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent router = RoutingAgent(api_mode=False) @@ -488,7 +488,7 @@ def test_cli_mode_asks_clarification_then_resolves( def test_cli_mode_empty_response_uses_defaults( self, _patch_create_client, mock_llm_client, _patch_code_agent ): - from gaia.agents.routing.agent import RoutingAgent + from gaia_agent_routing.agent import RoutingAgent router = RoutingAgent(api_mode=False) diff --git a/setup.py b/setup.py index 6b1f1197b..a4f00a3f3 100644 --- a/setup.py +++ b/setup.py @@ -56,7 +56,6 @@ "gaia.agents.builder", "gaia.agents.code_index", "gaia.agents.code_index.tools", - "gaia.agents.routing", "gaia.governance", "gaia.sd", "gaia.vlm", @@ -269,6 +268,8 @@ "agent-connectors-demo": ["gaia-agent-connectors-demo"], "agent-analyst": ["gaia-agent-analyst"], "agent-browser": ["gaia-agent-browser"], + "agent-docqa": ["gaia-agent-docqa"], + "agent-routing": ["gaia-agent-routing"], "agent-email": ["gaia-agent-email"], "agents": [ "gaia-agent-summarize", @@ -282,6 +283,8 @@ "gaia-agent-connectors-demo", "gaia-agent-analyst", "gaia-agent-browser", + "gaia-agent-docqa", + "gaia-agent-routing", "gaia-agent-email", ], }, diff --git a/src/gaia/agents/base/errors.py b/src/gaia/agents/base/errors.py index ea8f178b1..7633054da 100644 --- a/src/gaia/agents/base/errors.py +++ b/src/gaia/agents/base/errors.py @@ -22,7 +22,6 @@ "gaia/agents/code", "gaia/agents/docker", "gaia/agents/jira", - "gaia/agents/routing", "gaia/agents/tools", "site-packages/", } diff --git a/src/gaia/agents/routing/__init__.py b/src/gaia/agents/routing/__init__.py deleted file mode 100644 index 9860d45a5..000000000 --- a/src/gaia/agents/routing/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. -# SPDX-License-Identifier: MIT -"""Routing agent for intelligent agent selection and disambiguation.""" - -from .agent import RoutingAgent - -__all__ = ["RoutingAgent"] diff --git a/src/gaia/api/agent_registry.py b/src/gaia/api/agent_registry.py index d733d2cb8..e66ee4172 100644 --- a/src/gaia/api/agent_registry.py +++ b/src/gaia/api/agent_registry.py @@ -30,7 +30,8 @@ # These are the "models" exposed in /v1/models and selectable in VSCode AGENT_MODELS = { "gaia-code": { - "class_name": "gaia.agents.routing.agent.RoutingAgent", + # RoutingAgent ships as the standalone gaia-agent-routing wheel (#1102). + "class_name": "gaia_agent_routing.agent.RoutingAgent", "init_params": { "api_mode": True, # Skip interactive questions, use defaults/best-guess "silent_mode": True, @@ -98,7 +99,7 @@ def _load_agent_class(self, class_path: str) -> type: Dynamically load agent class from module path. Args: - class_path: Full module path (e.g., "gaia.agents.routing.agent.RoutingAgent") + class_path: Full module path (e.g., "gaia_agent_routing.agent.RoutingAgent") Returns: Agent class @@ -156,7 +157,19 @@ def get_agent(self, model_id: str) -> Agent: return agent_class(**init_params) except ImportError as e: logger.error(f"Failed to load agent {model_id}: {e}") - raise ValueError(f"Agent {model_id} not available: {e}") + hint = "" + if model_id == "gaia-code": + # gaia-code routes through the standalone gaia-agent-routing wheel + # (#1102), which in turn needs gaia-agent-code. Fail loudly with + # an install hint rather than degrading silently. + hint = ( + " The 'gaia-code' model routes through RoutingAgent, which " + "ships as the 'gaia-agent-routing' wheel. Install it with " + "'pip install gaia-agent-routing gaia-agent-code' (or " + "'pip install amd-gaia[agents]'). See " + "docs/spec/agent-hub-restructure.mdx." + ) + raise ValueError(f"Agent {model_id} not available: {e}.{hint}") from e def list_models(self) -> List[Dict[str, Any]]: """ diff --git a/src/gaia/cli.py b/src/gaia/cli.py index b7d394234..972c23847 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -2814,8 +2814,19 @@ def build_parser(): "--profile", "-p", default="chat", - choices=["minimal", "sd", "chat", "code", "rag", "mcp", "vlm", "npu", "all"], - help="Profile to initialize: minimal, sd (image gen), chat, code, rag, mcp, vlm (vision), npu (Ryzen AI NPU), all (default: chat)", + choices=[ + "minimal", + "sd", + "chat", + "code", + "rag", + "mcp", + "vlm", + "email", + "npu", + "all", + ], + help="Profile to initialize: minimal, sd (image gen), chat, code, rag, mcp, vlm (vision), email (Gmail/Outlook triage), npu (Ryzen AI NPU), all (default: chat)", ) init_parser.add_argument( "--minimal", diff --git a/src/gaia/installer/init_command.py b/src/gaia/installer/init_command.py index c9cafa18a..9d24a104f 100644 --- a/src/gaia/installer/init_command.py +++ b/src/gaia/installer/init_command.py @@ -95,6 +95,18 @@ "min_context_size": 32768, "pip_extras": [], }, + "email": { + "description": "Email triage for Gmail/Outlook (local inference)", + "agent": "email", + "models": ["Gemma-4-E4B-it-GGUF"], + "approx_size": "~3 GB", + # Keep in lock-step with gaia_agent_email.version.MIN_LEMONADE_VERSION + # and the email gaia-agent.yaml manifest (the GET /v1/email/init readiness + # check reads the same minimum). A test asserts the three agree. + "min_lemonade_version": "10.2.0", + "min_context_size": 32768, + "pip_extras": [], + }, "npu": { "description": "Ryzen AI NPU acceleration via FLM backend (requires XDNA2 NPU)", "agent": "chat", diff --git a/tests/test_email_openapi_conformance.py b/tests/test_email_openapi_conformance.py index 2cad650b0..1b842f923 100644 --- a/tests/test_email_openapi_conformance.py +++ b/tests/test_email_openapi_conformance.py @@ -173,6 +173,58 @@ def test_version_has_no_undocumented_fields(client, committed_spec): ), f"response field {key!r} is not documented in the spec schema" +# --------------------------------------------------------------------------- +# 2b. /init — readiness conformance (#1795). Probes patched so no live LLM. +# --------------------------------------------------------------------------- + + +def test_init_conforms_to_spec_when_not_ready(client, committed_spec): + """GET /init returns 503 + an InitResponse-shaped body when Lemonade is down. + + The running server returns the SAME structured model under 503 as under 200, + so the body must carry every required key from the documented schema. + """ + with patch( + "gaia_agent_email.api_routes._probe_lemonade_health", + return_value=(False, "http://localhost:8000/api/v1", None), + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 503 + schema_name = _schema_name_from_response(committed_spec, "get", "/v1/email/init") + required = _required_keys(committed_spec, schema_name) + body = resp.json() + for key in required: + assert key in body, f"required key {key!r} missing from /init response" + assert body["ready"] is False + assert body["hint"] # actionable, non-empty + + +def test_init_conforms_to_spec_when_ready(client, committed_spec): + """GET /init returns 200 + ready=True when both probes pass.""" + with ( + patch( + "gaia_agent_email.api_routes._probe_lemonade_health", + return_value=(True, "http://localhost:8000/api/v1", "10.2.0"), + ), + patch( + "gaia_agent_email.api_routes._probe_model_present", + return_value=True, + ), + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 200 + body = resp.json() + assert body["ready"] is True + # No undocumented fields leak from the running server. + documented = set( + committed_spec["components"]["schemas"]["InitResponse"].get("properties", {}) + ) + for key in body: + assert key in documented, f"undocumented field {key!r} in /init response" + + # --------------------------------------------------------------------------- # 3. /triage — conformance (LLM mocked; real HTTP layer running) # --------------------------------------------------------------------------- @@ -307,6 +359,7 @@ def test_all_documented_paths_covered(committed_spec): ("post", "/v1/email/send"), ("get", "/v1/email/health"), ("get", "/v1/email/version"), + ("get", "/v1/email/init"), } assert documented == expected, ( f"Spec has routes not covered by conformance tests: " diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 9ff4cb2dc..289fb9976 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -1246,8 +1246,12 @@ def test_routing_agent_exposes_process_query(self): """Verify RoutingAgent imports cleanly and exposes process_query. We don't instantiate — RoutingAgent.__init__ constructs a LemonadeClient. + + RoutingAgent ships as the standalone gaia-agent-routing wheel (#1102); + skip when a framework-only env lacks it. """ - from gaia.agents.routing.agent import RoutingAgent + pytest.importorskip("gaia_agent_routing") + from gaia_agent_routing.agent import RoutingAgent assert hasattr(RoutingAgent, "process_query") diff --git a/tests/unit/agents/email/test_init_endpoint.py b/tests/unit/agents/email/test_init_endpoint.py new file mode 100644 index 000000000..c650260ec --- /dev/null +++ b/tests/unit/agents/email/test_init_endpoint.py @@ -0,0 +1,618 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Unit tests for the email agent readiness endpoint ``GET /v1/email/init`` (#1795). + +The endpoint is a read-only preflight over the whole triage stack: it probes the +local Lemonade Server and confirms the triage model is downloaded, returning a +structured status (200 when ready, 503 when not, with an actionable hint). Unlike +``/health`` it does touch the LLM backend — but only with cheap probes, never a +model load or pull. + +These tests follow the repo's "verify call validity at boundaries" rule +(CLAUDE.md): the probe helpers are exercised against a mocked ``requests.get`` and +the SHAPE of the outgoing call is asserted (URL suffix, short timeout, auth +header), not merely that a call happened. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# EmailTriageAgent ships as the standalone gaia-agent-email wheel (#1102); +# skip cleanly when a framework-only env lacks it. +pytest.importorskip("gaia_agent_email") + +import gaia_agent_email.api_routes as ar # noqa: E402 +import requests # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from gaia_agent_email.api_routes import ( # noqa: E402 + _probe_lemonade_health, + _probe_lemonade_reachable, + _probe_model_present, + _pull_model, + _resolve_email_model_id, + _resolve_probe_base, +) +from gaia_agent_email.export_openapi import build_app # noqa: E402 +from gaia_agent_email.version import MIN_LEMONADE_VERSION # noqa: E402 + +from gaia.llm.lemonade_client import DEFAULT_MODEL_NAME # noqa: E402 + +# The short pre-flight timeout pair (#1677) — every probe must use it so an +# unreachable server fails fast instead of blocking on the OS SYN timeout. +_EXPECTED_TIMEOUT = ( + ar._LEMONADE_PROBE_CONNECT_TIMEOUT, + ar._LEMONADE_PROBE_READ_TIMEOUT, +) + + +@pytest.fixture +def client() -> TestClient: + """In-process ASGI client mounting only the email router — no mailbox, no LLM.""" + return TestClient(build_app()) + + +# --------------------------------------------------------------------------- +# 1. Base-URL resolution +# --------------------------------------------------------------------------- + + +def test_resolve_probe_base_appends_api_v1_when_missing(): + assert ( + _resolve_probe_base("http://localhost:9999") == "http://localhost:9999/api/v1" + ) + + +def test_resolve_probe_base_keeps_existing_api_v1_and_strips_trailing_slash(): + assert ( + _resolve_probe_base("http://localhost:9999/api/v1/") + == "http://localhost:9999/api/v1" + ) + + +# --------------------------------------------------------------------------- +# 2. Reachability probe — boundary shape +# --------------------------------------------------------------------------- + + +def test_reachable_probe_targets_health_with_short_timeout(): + with patch("requests.get") as mock_get: + mock_get.return_value = MagicMock(status_code=200) + reachable, base = _probe_lemonade_reachable("http://localhost:9999") + + assert reachable is True + assert base == "http://localhost:9999/api/v1" + args, kwargs = mock_get.call_args + # The probe MUST hit /api/v1/health (not the bare host) with the short + # connect/read timeout — assert the validity of the outgoing call. + assert args[0] == "http://localhost:9999/api/v1/health" + assert kwargs["timeout"] == _EXPECTED_TIMEOUT + + +def test_reachable_probe_treats_any_http_response_as_up(): + # A 500 from Lemonade still means the *server* is up — only a transport + # failure counts as unreachable. + with patch("requests.get") as mock_get: + mock_get.return_value = MagicMock(status_code=500) + reachable, _ = _probe_lemonade_reachable("http://localhost:9999") + assert reachable is True + + +def test_unreachable_probe_returns_false_not_raises(): + with patch("requests.get", side_effect=requests.exceptions.ConnectionError("boom")): + reachable, base = _probe_lemonade_reachable("http://localhost:9999") + assert reachable is False + assert base == "http://localhost:9999/api/v1" + + +def test_health_probe_extracts_server_version_from_body(): + resp = MagicMock(status_code=200) + resp.json.return_value = {"version": "10.3.1", "all_models_loaded": []} + with patch("requests.get", return_value=resp) as mock_get: + reachable, base, version = _probe_lemonade_health("http://localhost:9999") + assert reachable is True + assert base == "http://localhost:9999/api/v1" + assert version == "10.3.1" + # Shares the same /health probe target + short timeout as reachability. + args, kwargs = mock_get.call_args + assert args[0] == "http://localhost:9999/api/v1/health" + assert kwargs["timeout"] == _EXPECTED_TIMEOUT + + +def test_health_probe_version_none_when_not_advertised(): + resp = MagicMock(status_code=200) + resp.json.return_value = {"all_models_loaded": []} # no 'version' key + with patch("requests.get", return_value=resp): + _, _, version = _probe_lemonade_health("http://localhost:9999") + assert version is None + + +def test_health_probe_version_none_when_body_not_json(): + resp = MagicMock(status_code=200) + resp.json.side_effect = ValueError("no json") + with patch("requests.get", return_value=resp): + reachable, _, version = _probe_lemonade_health("http://localhost:9999") + # Server is up (HTTP responded) even though the body wasn't JSON. + assert reachable is True + assert version is None + + +# --------------------------------------------------------------------------- +# 3. Model-presence probe — boundary shape +# --------------------------------------------------------------------------- + + +def test_model_present_queries_models_endpoint_with_short_timeout(): + resp = MagicMock() + resp.json.return_value = {"data": [{"id": DEFAULT_MODEL_NAME}, {"id": "other"}]} + with patch("requests.get", return_value=resp) as mock_get: + present = _probe_model_present( + "http://localhost:9999/api/v1", DEFAULT_MODEL_NAME + ) + + assert present is True + args, kwargs = mock_get.call_args + assert args[0] == "http://localhost:9999/api/v1/models" + assert kwargs["timeout"] == _EXPECTED_TIMEOUT + + +def test_model_absent_when_id_not_in_list(): + resp = MagicMock() + resp.json.return_value = {"data": [{"id": "some-other-model"}]} + with patch("requests.get", return_value=resp): + present = _probe_model_present( + "http://localhost:9999/api/v1", DEFAULT_MODEL_NAME + ) + assert present is False + + +def test_model_probe_sends_auth_header_when_key_set(monkeypatch): + monkeypatch.setenv("LEMONADE_API_KEY", "secret-key") + resp = MagicMock() + resp.json.return_value = {"data": []} + with patch("requests.get", return_value=resp) as mock_get: + _probe_model_present("http://localhost:9999/api/v1", DEFAULT_MODEL_NAME) + _, kwargs = mock_get.call_args + # An authenticated Lemonade server must receive the Bearer header or it 401s. + assert kwargs["headers"].get("Authorization") == "Bearer secret-key" + + +# --------------------------------------------------------------------------- +# 4. Model-id resolution mirrors the agent's own resolution +# --------------------------------------------------------------------------- + + +def test_resolve_email_model_id_defaults_to_agent_default(): + # With no config override the readiness probe reports the same model the + # triage path loads (config.model_id or DEFAULT_MODEL_NAME). + assert _resolve_email_model_id() == DEFAULT_MODEL_NAME + + +# --------------------------------------------------------------------------- +# 5. Route — ready / not-ready status codes and structured body +# --------------------------------------------------------------------------- + +_BASE = "http://localhost:8000/api/v1" + + +def _patch_health(version): + """Patch the GET readiness path's /health probe to a reachable server at + ``version`` (None = server doesn't advertise one).""" + return patch.object( + ar, "_probe_lemonade_health", return_value=(True, _BASE, version) + ) + + +def test_init_ready_returns_200(client): + with ( + _patch_health(MIN_LEMONADE_VERSION), + patch.object(ar, "_probe_model_present", return_value=True), + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 200 + body = resp.json() + assert body["ready"] is True + assert body["lemonade"] == { + "reachable": True, + "base_url": _BASE, + "version": MIN_LEMONADE_VERSION, + "min_version": MIN_LEMONADE_VERSION, + "compatible": True, + } + assert body["model"]["present"] is True + assert body["model"]["id"] == DEFAULT_MODEL_NAME + # loadable is not probed in v1 — null, never a fabricated bool. + assert body["model"]["loadable"] is None + assert body["hint"] is None + + +def test_init_lemonade_down_returns_503_with_actionable_hint(client): + with patch.object(ar, "_probe_lemonade_health", return_value=(False, _BASE, None)): + resp = client.get("/v1/email/init") + + assert resp.status_code == 503 + body = resp.json() + assert body["ready"] is False + assert body["lemonade"]["reachable"] is False + # Hint names what failed AND what to do (CLAUDE.md fail-loudly rule). + assert "not reachable" in body["hint"] + assert "lemonade-server serve" in body["hint"] + # Model is not probed once Lemonade is down — reported absent, not crashed. + assert body["model"]["present"] is False + + +def test_init_lemonade_too_old_returns_503_with_upgrade_hint(client): + # Reachable + model present, but the server is older than the required + # minimum → not ready, with a found-vs-required upgrade hint. + with ( + _patch_health("9.0.0"), + patch.object(ar, "_probe_model_present", return_value=True), + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 503 + body = resp.json() + assert body["ready"] is False + assert body["lemonade"]["version"] == "9.0.0" + assert body["lemonade"]["min_version"] == MIN_LEMONADE_VERSION + assert body["lemonade"]["compatible"] is False + assert "9.0.0" in body["hint"] + assert MIN_LEMONADE_VERSION in body["hint"] + assert "upgrade" in body["hint"].lower() + + +def test_init_too_old_takes_priority_over_missing_model(client): + # An older-than-min Lemonade is the more fundamental blocker — its hint wins + # even when the model is also absent (upgrade first). + with ( + _patch_health("9.0.0"), + patch.object(ar, "_probe_model_present", return_value=False), + ): + body = client.get("/v1/email/init").json() + assert body["ready"] is False + assert body["lemonade"]["compatible"] is False + assert "older than" in body["hint"] + + +def test_init_newer_lemonade_is_compatible(client): + with ( + _patch_health("11.5.0"), + patch.object(ar, "_probe_model_present", return_value=True), + ): + resp = client.get("/v1/email/init") + body = resp.json() + assert resp.status_code == 200 + assert body["ready"] is True + assert body["lemonade"]["compatible"] is True + + +def test_init_unknown_version_is_indeterminate_not_blocking(client): + # Server didn't advertise a version → compatible=null and readiness is NOT + # blocked on it (mirrors gaia init's don't-block-on-unparseable policy). + with ( + _patch_health(None), + patch.object(ar, "_probe_model_present", return_value=True), + ): + resp = client.get("/v1/email/init") + body = resp.json() + assert resp.status_code == 200 + assert body["ready"] is True + assert body["lemonade"]["version"] is None + assert body["lemonade"]["compatible"] is None + assert body["hint"] is None + + +def test_init_model_missing_returns_503_with_model_hint(client): + with ( + _patch_health(MIN_LEMONADE_VERSION), + patch.object(ar, "_probe_model_present", return_value=False), + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 503 + body = resp.json() + assert body["ready"] is False + assert body["lemonade"]["reachable"] is True + assert body["lemonade"]["compatible"] is True + assert body["model"]["present"] is False + assert body["model"]["id"] == DEFAULT_MODEL_NAME + assert "not downloaded" in body["hint"] + assert "gaia init" in body["hint"] + + +def test_init_model_list_unreadable_returns_503_loudly(client): + # Lemonade answered /health but its /models list errored — surface loudly + # (503 + hint), never silently report "present". + with ( + _patch_health(MIN_LEMONADE_VERSION), + patch.object( + ar, + "_probe_model_present", + side_effect=requests.exceptions.ConnectionError("reset"), + ), + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 503 + body = resp.json() + assert body["ready"] is False + assert "model list" in body["hint"] + + +def test_init_response_forbids_unknown_fields(client): + # _Strict response models — the serialized body carries exactly the + # documented keys, no silent extras. + with ( + _patch_health(MIN_LEMONADE_VERSION), + patch.object(ar, "_probe_model_present", return_value=True), + ): + body = client.get("/v1/email/init").json() + assert set(body) == {"ready", "lemonade", "model", "hint"} + assert set(body["lemonade"]) == { + "reachable", + "base_url", + "version", + "min_version", + "compatible", + } + assert set(body["model"]) == {"id", "present", "loadable"} + + +# --------------------------------------------------------------------------- +# 5b. Version helpers + manifest lock-step +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("found", "minimum", "expected"), + [ + ("10.2.0", "10.2.0", True), + ("10.3.0", "10.2.0", True), + ("11.0.0", "10.2.0", True), + ("9.9.9", "10.2.0", False), + ("10.1.9", "10.2.0", False), + ("v10.2.0", "10.2.0", True), # leading 'v' tolerated + (None, "10.2.0", None), # unknown → indeterminate + ("not-a-version", "10.2.0", None), + ], +) +def test_version_meets_min(found, minimum, expected): + from gaia_agent_email.api_routes import _version_meets_min + + assert _version_meets_min(found, minimum) is expected + + +def test_min_lemonade_version_locksteps_with_manifest(): + # ONE source of truth: the runtime constant and the gaia-agent.yaml manifest + # value `gaia init` reads MUST match, or readiness and install disagree. + import gaia_agent_email + import yaml + from gaia_agent_email.version import MIN_LEMONADE_VERSION as RUNTIME_MIN + + manifest_path = ( + Path(gaia_agent_email.__file__).resolve().parents[1] / "gaia-agent.yaml" + ) + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + declared = manifest["requirements"]["min_lemonade_version"] + assert declared == RUNTIME_MIN, ( + f"gaia-agent.yaml min_lemonade_version ({declared!r}) != " + f"version.MIN_LEMONADE_VERSION ({RUNTIME_MIN!r}) — keep in lock-step." + ) + + +# --------------------------------------------------------------------------- +# 6. OpenAPI + sidecar mount +# --------------------------------------------------------------------------- + + +def test_init_route_in_openapi_with_init_response_model(client): + spec = build_app().openapi() + assert "/v1/email/init" in spec["paths"] + init = spec["paths"]["/v1/email/init"]["get"] + schema = init["responses"]["200"]["content"]["application/json"]["schema"] + assert schema == {"$ref": "#/components/schemas/InitResponse"} + assert "503" in init["responses"] + assert "InitResponse" in spec["components"]["schemas"] + + +def test_init_route_mounted_via_packaging_server(): + # The frozen-binary sidecar entrypoint (packaging/server.py) must also serve + # the route. Loaded by file path to dodge the stdlib ``packaging`` name + # collision. + import importlib.util + + import gaia_agent_email + + server_path = ( + Path(gaia_agent_email.__file__).resolve().parents[1] / "packaging" / "server.py" + ) + spec = importlib.util.spec_from_file_location("_email_sidecar_server", server_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + app = module.build_app() + # Prove reachability with a real request rather than route introspection: + # depending on the FastAPI version ``include_router`` either flattens routes + # into ``app.routes`` or keeps them under a mounted sub-router, so a + # ``.path`` scan is version-fragile. An HTTP request is not — the route is + # mounted iff the sidecar app serves it (503 here, Lemonade is down in the + # test env), and a 404 would prove it is NOT mounted. Probe patched so the + # test never hits the network. + with patch.object( + ar, + "_probe_lemonade_health", + return_value=(False, "http://localhost:8000/api/v1", None), + ): + resp = TestClient(app).get("/v1/email/init") + assert resp.status_code != 404, "/v1/email/init is not mounted on the sidecar app" + assert resp.status_code == 503 + assert resp.json()["ready"] is False + + +# --------------------------------------------------------------------------- +# 7. POST /v1/email/init — provisioning (#1795 follow-up). Streams progress. +# --------------------------------------------------------------------------- + + +def test_pull_model_posts_only_model_name_no_recipe(): + # Built-in models must NOT carry `recipe` or Lemonade 400s (#1655). Assert + # the SHAPE of the outgoing pull, not merely that it was called. + resp = MagicMock() + with patch("requests.post", return_value=resp) as mock_post: + _pull_model("http://localhost:9999/api/v1", DEFAULT_MODEL_NAME) + args, kwargs = mock_post.call_args + assert args[0] == "http://localhost:9999/api/v1/pull" + assert kwargs["json"] == {"model_name": DEFAULT_MODEL_NAME} + assert "recipe" not in kwargs["json"] + resp.raise_for_status.assert_called_once() # non-2xx pulls fail loudly + + +def test_pull_model_sends_auth_header_when_key_set(monkeypatch): + monkeypatch.setenv("LEMONADE_API_KEY", "secret-key") + with patch("requests.post", return_value=MagicMock()) as mock_post: + _pull_model("http://localhost:9999/api/v1", DEFAULT_MODEL_NAME) + _, kwargs = mock_post.call_args + assert kwargs["headers"].get("Authorization") == "Bearer secret-key" + + +def test_provision_lemonade_down_returns_503_streamed_actionable(client): + with ( + patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(False, "http://localhost:8000/api/v1"), + ), + patch.object(ar, "_pull_model") as mock_pull, + ): + resp = client.post("/v1/email/init") + + assert resp.status_code == 503 + assert resp.headers["content-type"].startswith("text/plain") + body = resp.text + assert "not reachable" in body + assert "lemonade-server serve" in body + # No pull is attempted when Lemonade is down — the sidecar can't install it. + mock_pull.assert_not_called() + + +def test_provision_model_already_present_streams_done_without_pull(client): + with ( + patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + patch.object(ar, "_probe_model_present", return_value=True), + patch.object(ar, "_pull_model") as mock_pull, + ): + resp = client.post("/v1/email/init") + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/plain") + body = resp.text + assert "already downloaded" in body + # Final authoritative line is a success marker. + assert body.rstrip().splitlines()[-1].startswith("✓") + mock_pull.assert_not_called() + + +def test_provision_pulls_missing_model_and_streams_success(client): + # First presence check → absent (triggers pull); post-pull verify → present. + with ( + patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + patch.object(ar, "_probe_model_present", side_effect=[False, True]), + patch.object(ar, "_pull_model") as mock_pull, + ): + resp = client.post("/v1/email/init") + + assert resp.status_code == 200 + body = resp.text + assert "Pulling" in body + assert "downloaded" in body + assert "Verified" in body + assert body.rstrip().splitlines()[-1].startswith("✓") + # Pull was invoked once for the resolved model against the probed server. + mock_pull.assert_called_once_with( + "http://localhost:8000/api/v1", DEFAULT_MODEL_NAME + ) + + +def test_provision_pull_failure_streams_failure_line(client): + with ( + patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + patch.object(ar, "_probe_model_present", side_effect=[False]), + patch.object( + ar, + "_pull_model", + side_effect=requests.exceptions.HTTPError("400 Bad Request"), + ), + ): + resp = client.post("/v1/email/init") + + # Status was committed to 200 once streaming began; the final ✗ line is the + # authoritative failure signal (documented HTTP-streaming constraint). + assert resp.status_code == 200 + body = resp.text + assert "Provisioning failed" in body + assert body.rstrip().splitlines()[-1].startswith("✗") + + +def test_provision_model_list_unreadable_aborts(client): + with ( + patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + patch.object( + ar, + "_probe_model_present", + side_effect=requests.exceptions.ConnectionError("reset"), + ), + patch.object(ar, "_pull_model") as mock_pull, + ): + resp = client.post("/v1/email/init") + + body = resp.text + assert "model list" in body + assert body.rstrip().splitlines()[-1].startswith("✗") + mock_pull.assert_not_called() + + +def test_provision_verb_not_in_openapi_contract(): + # POST is a streaming operational verb (like GET /spec), deliberately kept + # out of the JSON contract so the cross-impl OpenAPI stays JSON-only. + spec = build_app().openapi() + assert "post" not in spec["paths"].get("/v1/email/init", {}) + + +def test_get_init_still_readiness_only_unchanged(client): + # Guard: adding POST must not change GET's readiness semantics. + with ( + patch.object( + ar, + "_probe_lemonade_health", + return_value=(True, "http://localhost:8000/api/v1", MIN_LEMONADE_VERSION), + ), + patch.object(ar, "_probe_model_present", return_value=True), + patch.object(ar, "_pull_model") as mock_pull, + ): + resp = client.get("/v1/email/init") + + assert resp.status_code == 200 + assert resp.json()["ready"] is True + # GET never provisions. + mock_pull.assert_not_called() diff --git a/tests/unit/agents/email/test_spec_html.py b/tests/unit/agents/email/test_spec_html.py index facb78690..166ac1595 100644 --- a/tests/unit/agents/email/test_spec_html.py +++ b/tests/unit/agents/email/test_spec_html.py @@ -80,6 +80,23 @@ def test_send_endpoint_present(): assert "/v1/email/send" in _html() +def test_init_endpoint_present(): + # Readiness preflight (#1795) must be documented on the spec page. + html = _html() + assert "/v1/email/init" in html + assert "InitResponse" in html + # The GET method badge must render (init is the only GET endpoint shown). + assert ">GET<" in html + + +def test_provision_verb_documented(): + # The POST provisioning verb (#1795 follow-up) streams progress and is not in + # the JSON OpenAPI, so the HTML spec is where it must be documented. + html = _html() + assert "stream terminal-style progress" in html.lower() + assert "text/plain" in html + + # --------------------------------------------------------------------------- # Contract field names — sourced from the models so a contract change that # drops a field will break this test, not slip through silently. diff --git a/tests/unit/agents/test_default_max_steps.py b/tests/unit/agents/test_default_max_steps.py index cd457d8f0..e6546b9c9 100644 --- a/tests/unit/agents/test_default_max_steps.py +++ b/tests/unit/agents/test_default_max_steps.py @@ -43,12 +43,12 @@ def test_non_positive_raises_loudly(self): default_max_steps() def test_configs_inherit_the_override_at_construction(self): + from gaia.agents.builder.agent import BuilderAgentConfig from gaia.agents.chat.agent import ChatAgentConfig - from gaia.agents.docqa.agent import DocumentQAAgentConfig with mock.patch.dict(os.environ, {"GAIA_AGENT_MAX_STEPS": "42"}): self.assertEqual(ChatAgentConfig().max_steps, 42) - self.assertEqual(DocumentQAAgentConfig().max_steps, 42) + self.assertEqual(BuilderAgentConfig().max_steps, 42) if __name__ == "__main__": diff --git a/tests/unit/test_agent_pypi_publish.py b/tests/unit/test_agent_pypi_publish.py index b639efd25..295e691e0 100644 --- a/tests/unit/test_agent_pypi_publish.py +++ b/tests/unit/test_agent_pypi_publish.py @@ -28,6 +28,10 @@ UTIL_DIR = REPO_ROOT / "util" WORKFLOW = REPO_ROOT / ".github" / "workflows" / "publish_agents.yml" +# Infrastructure agents publish as wheels but are loaded by class-path from the +# API server, not discovered via the gaia.agent registry entry point (#1102). +INFRA_ONLY_AGENT_IDS = {"routing"} + if str(UTIL_DIR) not in sys.path: sys.path.insert(0, str(UTIL_DIR)) @@ -82,8 +86,14 @@ def test_pyproject_name_matches_dist(packages): def test_pyproject_declares_gaia_agent_entry_point(packages): - """Both install paths (R2 and pip) discover the agent via gaia.agent.""" + """Both install paths (R2 and pip) discover the agent via gaia.agent. + + Infrastructure agents (e.g. routing) are exempt — they are resolved by + class-path from the API server, not via the registry entry point (#1102). + """ for p in packages: + if p.agent_id in INFRA_ONLY_AGENT_IDS: + continue pyproject = (p.path / "pyproject.toml").read_text(encoding="utf-8") assert ( 'entry-points."gaia.agent"' in pyproject diff --git a/tests/unit/test_agents_split.py b/tests/unit/test_agents_split.py index fd86f40c7..15461fa89 100644 --- a/tests/unit/test_agents_split.py +++ b/tests/unit/test_agents_split.py @@ -5,11 +5,13 @@ def test_instantiate_new_agents(): - # fileio ships as the standalone gaia-agent-fileio wheel (#1102). + # fileio/docqa ship as the standalone gaia-agent-fileio / gaia-agent-docqa + # wheels (#1102). pytest.importorskip("gaia_agent_fileio") + pytest.importorskip("gaia_agent_docqa") # Import without triggering heavy optional deps by relying on skip_lemonade chat_mod = import_module("gaia.agents.chat.lite_agent") - docqa_mod = import_module("gaia.agents.docqa.agent") + docqa_mod = import_module("gaia_agent_docqa.agent") fileio_mod = import_module("gaia_agent_fileio.agent") chat = chat_mod.ChatAgentLite() @@ -156,12 +158,13 @@ def test_get_mcp_status_report_does_not_raise(tmp_path): pytest.importorskip("gaia_agent_fileio") pytest.importorskip("gaia_agent_browser") pytest.importorskip("gaia_agent_analyst") + pytest.importorskip("gaia_agent_docqa") from gaia_agent_analyst.agent import AnalystAgent, AnalystAgentConfig from gaia_agent_browser.agent import BrowserAgent + from gaia_agent_docqa.agent import DocumentQAAgent from gaia_agent_fileio.agent import FileIOAgent from gaia.agents.chat.lite_agent import ChatAgentLite - from gaia.agents.docqa.agent import DocumentQAAgent agents = [ BrowserAgent(), diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 03ccb16da..8a62f34f9 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -75,12 +75,15 @@ def test_framework_paths_includes_all_agents(self): "gaia/agents/code", "gaia/agents/docker", "gaia/agents/jira", - "gaia/agents/routing", "gaia/agents/tools", "site-packages/", ] for path in expected_paths: assert path in FRAMEWORK_PATHS, f"Missing framework path: {path}" + # RoutingAgent migrated to the gaia-agent-routing wheel (#1102); its + # frames are now filtered via the "site-packages/" entry, so the old + # "gaia/agents/routing" path must no longer be listed. + assert "gaia/agents/routing" not in FRAMEWORK_PATHS def test_framework_paths_no_redundant_entries(self): """Verify no redundant site-packages entries.""" diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 45a7fbbcb..358eea6e4 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -279,6 +279,36 @@ def test_profiles_have_required_keys(self): for key in required_keys: self.assertIn(key, profile, f"Profile '{name}' missing key '{key}'") + def test_email_profile_defined(self): + """`gaia init --profile email` downloads the email triage model.""" + from gaia.installer.init_command import INIT_PROFILES + + self.assertIn("email", INIT_PROFILES) + email = INIT_PROFILES["email"] + self.assertEqual(email["agent"], "email") + self.assertIn("Gemma-4-E4B-it-GGUF", email["models"]) + + def test_email_profile_min_version_locksteps_with_agent(self): + """The email init profile's min Lemonade version must match the email + agent's runtime minimum — readiness (/v1/email/init) and the installer + must agree on what 'compatible' means.""" + from gaia.installer.init_command import INIT_PROFILES + + try: + from gaia_agent_email.version import MIN_LEMONADE_VERSION + except ImportError: + self.skipTest("gaia_agent_email (standalone email wheel) not installed") + self.assertEqual( + INIT_PROFILES["email"]["min_lemonade_version"], MIN_LEMONADE_VERSION + ) + + def test_email_profile_is_a_cli_choice(self): + """The init subparser must accept --profile email (argparse choices).""" + from gaia.cli import build_parser + + ns = build_parser().parse_args(["init", "--profile", "email"]) + self.assertEqual(ns.profile, "email") + class TestRemoteAutoDetection(unittest.TestCase): """Test auto-detection of remote mode from LEMONADE_BASE_URL.""" diff --git a/util/check_agent_conventions.py b/util/check_agent_conventions.py index 270d9e7bd..1208db790 100644 --- a/util/check_agent_conventions.py +++ b/util/check_agent_conventions.py @@ -40,9 +40,11 @@ # Directories under src/gaia/agents/ that are NOT agents. _NON_AGENT_DIRS = {"__pycache__", "base", "tools"} -# Agent classes that are documented as "not-a-subclass-of-Agent-directly" -# (e.g. RoutingAgent wraps other agents) — accepted without the Agent base. -_STANDALONE_ALLOWED = {"RoutingAgent"} +# Agent classes scanned under src/gaia/agents/ that are documented as +# "not-a-subclass-of-Agent-directly" — accepted without the Agent base. +# RoutingAgent (the canonical example) now ships as the standalone +# gaia-agent-routing wheel (#1102) and is no longer scanned here. +_STANDALONE_ALLOWED: set = set() def _has_copyright_header(text: str) -> bool: diff --git a/util/lint.ps1 b/util/lint.ps1 index 42d64520f..ff4891e21 100644 --- a/util/lint.ps1 +++ b/util/lint.ps1 @@ -353,7 +353,8 @@ function Invoke-ImportTests { @{Import="from gaia.agents.docker import DockerAgent"; Desc="Docker agent"; Optional=$false}, @{Import="from gaia.agents.blender import BlenderAgent"; Desc="Blender agent"; Optional=$false}, @{Import="from gaia.agents.emr import MedicalIntakeAgent"; Desc="Medical intake agent"; Optional=$false}, - @{Import="from gaia.agents.routing import RoutingAgent"; Desc="Routing agent"; Optional=$false}, + @{Import="from gaia_agent_routing import RoutingAgent"; Desc="Routing agent"; Optional=$true}, + @{Import="from gaia_agent_docqa import DocumentQAAgent"; Desc="Document Q&A agent"; Optional=$true}, # Database @{Import="from gaia.database import DatabaseAgent"; Desc="Database agent"; Optional=$false}, diff --git a/util/lint.py b/util/lint.py index 21c89ed6f..2b2c016cb 100644 --- a/util/lint.py +++ b/util/lint.py @@ -358,7 +358,8 @@ def check_imports() -> CheckResult: ("from", "gaia_agent_jira", "JiraAgent", "Jira agent", True), ("from", "gaia_agent_docker", "DockerAgent", "Docker agent", True), ("from", "gaia_agent_blender", "BlenderAgent", "Blender agent", True), - ("from", "gaia.agents.routing", "RoutingAgent", "Routing agent", False), + ("from", "gaia_agent_routing", "RoutingAgent", "Routing agent", True), + ("from", "gaia_agent_docqa", "DocumentQAAgent", "Document Q&A agent", True), # Migrated to standalone wheels (#1102) — optional so a framework-only # env (no gaia-agent- installed) skips rather than fails. ("from", "gaia_agent_sd", "SDAgent", "SD agent", True),