From b05790209fd02c7a1db74d4f82e3fd47e0e2ff88 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Thu, 4 Jun 2026 14:56:25 -0700 Subject: [PATCH 01/12] refactor(agents): migrate docqa + routing to hub (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentQAAgent and RoutingAgent were the last two agents left in the core source tree under src/gaia/agents/. They now ship as standalone gaia-agent-docqa / gaia-agent-routing wheels under hub/agents/python/, completing the "strip src/gaia/agents/ to framework only" goal for #1102 (only base/, tools/, registry.py, builder/ — plus the chat family and email — remain in core). docqa is a building-block RAG agent: it registers via the gaia.agent entry point as a hidden agent (mirroring fileio), default model Qwen3.5-35B-A3B-GGUF. routing is infrastructure — a meta-agent loaded by class path from the OpenAI API server, not a registry agent — so it ships without a gaia.agent entry point; gaia.api.agent_registry now resolves it at gaia_agent_routing.agent.RoutingAgent and fails loudly with an install hint when the wheel is absent. --- .github/workflows/test_code_agent.yml | 4 +- .github/workflows/test_docqa_agent.yml | 63 ++++++++++++++++++ .github/workflows/test_gaia_cli.yml | 16 ++++- .github/workflows/test_routing_agent.yml | 65 +++++++++++++++++++ hub/agents/python/docqa/README.md | 22 +++++++ hub/agents/python/docqa/gaia-agent.yaml | 35 ++++++++++ .../python/docqa/gaia_agent_docqa/__init__.py | 55 ++++++++++++++++ .../python/docqa/gaia_agent_docqa}/agent.py | 0 hub/agents/python/docqa/pyproject.toml | 22 +++++++ .../python/docqa/tests}/test_docqa_agent.py | 8 +-- hub/agents/python/routing/README.md | 30 +++++++++ hub/agents/python/routing/gaia-agent.yaml | 35 ++++++++++ .../routing/gaia_agent_routing/__init__.py | 17 +++++ .../routing/gaia_agent_routing}/agent.py | 0 .../gaia_agent_routing}/system_prompt.py | 0 hub/agents/python/routing/pyproject.toml | 22 +++++++ .../routing/tests}/test_routing_agent.py | 18 ++--- setup.py | 5 +- src/gaia/agents/routing/__init__.py | 7 -- src/gaia/api/agent_registry.py | 19 +++++- tests/test_sdk.py | 6 +- tests/unit/test_agents_split.py | 9 ++- util/lint.ps1 | 3 +- util/lint.py | 3 +- 24 files changed, 431 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/test_docqa_agent.yml create mode 100644 .github/workflows/test_routing_agent.yml create mode 100644 hub/agents/python/docqa/README.md create mode 100644 hub/agents/python/docqa/gaia-agent.yaml create mode 100644 hub/agents/python/docqa/gaia_agent_docqa/__init__.py rename {src/gaia/agents/docqa => hub/agents/python/docqa/gaia_agent_docqa}/agent.py (100%) create mode 100644 hub/agents/python/docqa/pyproject.toml rename {tests/unit/agents => hub/agents/python/docqa/tests}/test_docqa_agent.py (85%) create mode 100644 hub/agents/python/routing/README.md create mode 100644 hub/agents/python/routing/gaia-agent.yaml create mode 100644 hub/agents/python/routing/gaia_agent_routing/__init__.py rename {src/gaia/agents/routing => hub/agents/python/routing/gaia_agent_routing}/agent.py (100%) rename {src/gaia/agents/routing => hub/agents/python/routing/gaia_agent_routing}/system_prompt.py (100%) create mode 100644 hub/agents/python/routing/pyproject.toml rename {tests/unit/agents => hub/agents/python/routing/tests}/test_routing_agent.py (97%) delete mode 100644 src/gaia/agents/routing/__init__.py diff --git a/.github/workflows/test_code_agent.yml b/.github/workflows/test_code_agent.yml index baf8bbf6d..a74860613 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: 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 709faf97f..d22283e49 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 Security features test-security: name: Security Tests @@ -105,7 +119,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-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-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/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 85% rename from tests/unit/agents/test_docqa_agent.py rename to hub/agents/python/docqa/tests/test_docqa_agent.py index 9c970f2a7..638936f59 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) @@ -22,7 +22,7 @@ def test_can_import(self): class TestDocumentQAAgentConfig(unittest.TestCase): def test_defaults(self): - from gaia.agents.docqa.agent import DocumentQAAgentConfig + from gaia_agent_docqa.agent import DocumentQAAgentConfig cfg = DocumentQAAgentConfig() self.assertFalse(cfg.use_claude) @@ -33,7 +33,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/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 946496535..ed451bc5c 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", @@ -250,6 +249,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"], "agents": [ "gaia-agent-summarize", "gaia-agent-sd", @@ -262,6 +263,8 @@ "gaia-agent-connectors-demo", "gaia-agent-analyst", "gaia-agent-browser", + "gaia-agent-docqa", + "gaia-agent-routing", ], }, classifiers=[ 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..7dc09e844 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}") def list_models(self) -> List[Dict[str, Any]]: """ 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/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/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), From dc71d626c3114a8a831194881f57b267be7ed4e5 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Thu, 4 Jun 2026 15:52:08 -0700 Subject: [PATCH 02/12] fix(agents): repoint gaia-agent-code at relocated RoutingAgent (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review follow-up to the docqa/routing migration: the gaia-agent-code CLI imported RoutingAgent from the old in-tree path (gaia.agents.routing.agent), which the migration broke. Repoint it at gaia_agent_routing.agent and declare gaia-agent-routing as a dependency of gaia-agent-code, since the `gaia-code` query path routes through RoutingAgent for language/project-type detection. No reverse dependency (routing → code) — routing resolves CodeAgent through the registry at runtime, avoiding a cycle. Also clears the now-dead RoutingAgent allowance in the agent-conventions checker (it only applied while routing lived under src/gaia/agents/). --- hub/agents/python/code/gaia_agent_code/cli.py | 6 ++++-- hub/agents/python/code/pyproject.toml | 4 +++- util/check_agent_conventions.py | 8 +++++--- 3 files changed, 12 insertions(+), 6 deletions(-) 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/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: From 633e365f2a37ab895e7e600d260b57ea71fff61b Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Wed, 17 Jun 2026 13:50:49 -0700 Subject: [PATCH 03/12] fix(agents): complete docqa+routing migration cleanup (#1102) Merging main surfaced three stale references the migration missed: - test_default_max_steps imported the now-migrated gaia.agents.docqa; repoint it at the core BuilderAgentConfig, which exercises the same field(default_factory=default_max_steps) inheritance. - test_agent_pypi_publish asserted every published wheel declares a gaia.agent entry point, but routing is infrastructure loaded by class-path and intentionally ships without one. Exempt it explicitly. - Routing module path + source links in the docs still pointed at src/gaia/agents/routing; repoint to the gaia_agent_routing wheel. Also preserve the original traceback on the gaia-code ImportError re-raise (raise ... from e) now that the block is being edited. --- docs/guides/routing.mdx | 6 +++--- docs/playbooks/chat-agent/part-3-deployment.mdx | 2 +- docs/sdk/agents/routing.mdx | 12 ++++++------ docs/sdk/infrastructure/api-server.mdx | 2 +- docs/spec/api-server.mdx | 2 +- docs/spec/routing-agent.mdx | 8 ++++---- src/gaia/api/agent_registry.py | 2 +- tests/unit/agents/test_default_max_steps.py | 4 ++-- tests/unit/test_agent_pypi_publish.py | 12 +++++++++++- 9 files changed, 30 insertions(+), 20 deletions(-) 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/src/gaia/api/agent_registry.py b/src/gaia/api/agent_registry.py index 7dc09e844..e66ee4172 100644 --- a/src/gaia/api/agent_registry.py +++ b/src/gaia/api/agent_registry.py @@ -169,7 +169,7 @@ def get_agent(self, model_id: str) -> Agent: "'pip install amd-gaia[agents]'). See " "docs/spec/agent-hub-restructure.mdx." ) - raise ValueError(f"Agent {model_id} not available: {e}.{hint}") + raise ValueError(f"Agent {model_id} not available: {e}.{hint}") from e def list_models(self) -> List[Dict[str, Any]]: """ 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 From f5e35862c956ae34b2b64f5b2241b88728fd5159 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Wed, 17 Jun 2026 14:00:20 -0700 Subject: [PATCH 04/12] ci(code-agent): install local routing wheel before code agent (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gaia-agent-code now depends on gaia-agent-routing>=0.1.0, which isn't published to PyPI. The Test Code Agent workflow installed code straight from the hub dir, so uv tried to resolve routing from the registry and failed. Install the local routing package first so the dep resolves locally. End users are unaffected — both wheels publish together on tag. --- .github/workflows/test_code_agent.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test_code_agent.yml b/.github/workflows/test_code_agent.yml index a74860613..fcda0e5ed 100644 --- a/.github/workflows/test_code_agent.yml +++ b/.github/workflows/test_code_agent.yml @@ -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 From 81b07b8b465d90057fa826add0154168ee1fc5d0 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Wed, 17 Jun 2026 14:10:52 -0700 Subject: [PATCH 05/12] ci(api): install routing+code wheels so gaia-code API path works (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API streaming tests target the 'gaia-code' model, which routes through RoutingAgent. Pre-migration routing lived in core, so it resolved automatically; now it ships as the gaia-agent-routing wheel that the API Tests job didn't install — so 3 streaming tests hit the (correct) missing-wheel error instead of a real agent. Install the local routing+code hub packages, and re-run API tests when either hub package changes. --- .github/workflows/test_api.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From 8fa14a5956f415447aabbfcd088893506a70dc07 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 18 Jun 2026 17:29:37 -0700 Subject: [PATCH 06/12] docs(agents): fix stale docqa/routing paths flagged in review (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md still pointed DocumentQAAgent/RoutingAgent at the old src/gaia/agents/{docqa,routing} locations and listed docqa in the source tree — stale after the hub migration and misleading since CLAUDE.md loads as context on every session. Point both at their hub wheels and drop the docqa tree entry. errors.py FRAMEWORK_PATHS carried a dead 'gaia/agents/routing' entry; the wheel's frames are already filtered by 'site-packages/'. Remove it and update the test that asserted its presence. --- CLAUDE.md | 7 +++---- src/gaia/agents/base/errors.py | 1 - tests/unit/test_errors.py | 5 ++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec7eef626..8efe5ee7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -405,7 +405,6 @@ gaia/ │ │ ├── code_index/ # CodeIndexToolsMixin — semantic code search (FAISS) │ │ ├── analyst/ # AnalystAgent — structured data analysis (CSV/Excel, scratchpad SQL) │ │ ├── browser/ # BrowserAgent — web research (search, fetch, download) -│ │ ├── docqa/ # DocumentQAAgent — standalone document Q&A with RAG │ │ ├── fileio/ # FileIOAgent — file read/write/edit operations │ │ ├── email/ # EmailTriageAgent — email triage and summarization │ │ ├── builder/ # BuilderAgent — scaffolds new agents from templates @@ -506,7 +505,7 @@ The `gaia-emr` console script now ships with the standalone `gaia-agent-emr` hub | Agent | Location | Description | Default Model | |-------|----------|-------------|---------------| | **ChatAgent** | `agents/chat/agent.py` | Multi-profile conversation (chat/doc/file) with RAG | Gemma-4-E4B | -| **DocumentQAAgent** | `agents/docqa/agent.py` | Standalone document Q&A with RAG | Qwen3.5-35B-A3B | +| **DocumentQAAgent** | `hub/agents/python/docqa/gaia_agent_docqa/agent.py` | Standalone document Q&A with RAG | Qwen3.5-35B-A3B | | **AnalystAgent** | `agents/analyst/agent.py` | Structured data analysis (CSV/Excel, scratchpad SQL) | Qwen3.5-35B-A3B | | **BrowserAgent** | `agents/browser/agent.py` | Web research — search, fetch pages, download | Qwen3.5-35B-A3B | | **FileIOAgent** | `agents/fileio/agent.py` | File read/write/edit operations | Qwen3.5-35B-A3B | @@ -518,11 +517,11 @@ The `gaia-emr` console script now ships with the standalone `gaia-agent-emr` hub | **BlenderAgent** | `agents/blender/agent.py` | 3D scene automation | Qwen3.5-35B-A3B | | **DockerAgent** | `agents/docker/agent.py` | Container management | Qwen3.5-35B-A3B | | **MedicalIntakeAgent** | `hub/agents/python/emr/gaia_agent_emr/agent.py` | Medical form processing (VLM) | Gemma-4-E4B | -| **RoutingAgent** | `agents/routing/agent.py` | Intelligent agent selection | Qwen3.5-35B-A3B (`AGENT_ROUTING_MODEL`) | +| **RoutingAgent** | `hub/agents/python/routing/gaia_agent_routing/agent.py` | Intelligent agent selection | Qwen3.5-35B-A3B (`AGENT_ROUTING_MODEL`) | | **SDAgent** | `agents/sd/agent.py` | Stable Diffusion image generation | SDXL-Turbo | | **ConnectorsDemoAgent** | `agents/connectors_demo/agent.py` | Per-agent connector activation demo | Qwen3.5-35B-A3B | -`gaia browse` and `gaia analyze` invoke BrowserAgent and AnalystAgent respectively (see [`src/gaia/cli.py`](src/gaia/cli.py)). `gaia telegram` is a messaging adapter, not an agent. Internal building-block agents (DocumentQAAgent, FileIOAgent, ConnectorsDemoAgent) live under `src/gaia/agents/` but aren't standalone CLI commands. +`gaia browse` and `gaia analyze` invoke BrowserAgent and AnalystAgent respectively (see [`src/gaia/cli.py`](src/gaia/cli.py)). `gaia telegram` is a messaging adapter, not an agent. Internal building-block agents (FileIOAgent, ConnectorsDemoAgent) live under `src/gaia/agents/` but aren't standalone CLI commands. DocumentQAAgent and RoutingAgent now ship as standalone `gaia-agent-docqa` / `gaia-agent-routing` hub wheels (`hub/agents/python/`). ### Agent Registry & Tool Mixins 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/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.""" From 2c9f0cea8c2acbb417a8438d65572dad2320dcc1 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Sun, 21 Jun 2026 16:19:05 -0700 Subject: [PATCH 07/12] feat(agent-email): add GET /v1/email/init readiness preflight (#1795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh host passed /health and /version green even with Lemonade down and no model downloaded, then 502'd on the first triage call. /v1/email/init probes the whole triage stack — Lemonade reachable + the triage model present — and returns a structured status (200 when ready, 503 when not, with an actionable hint), so integrators can verify "ready to triage," not just "process up." Read-only: probes only, no model pull or provisioning. `loadable` is reported null in v1 (forcing a load is heavy); `present` is the readiness signal. The Lemonade reachability probe reuses the existing short-timeout /health logic (#1677) via a shared base-URL resolver. Docs kept in sync: the runtime /v1/email/spec page, the hand-maintained specification.html, and the regenerated openapi.email.json. Verified the route also mounts via the frozen sidecar's build_app(). --- .../email/gaia_agent_email/api_routes.py | 233 +++++++++++++- .../email/gaia_agent_email/spec_html.py | 28 +- hub/agents/python/email/openapi.email.json | 126 ++++++++ hub/agents/python/email/specification.html | 14 +- .../python/email/tests/test_rest_contract.py | 1 + tests/test_email_openapi_conformance.py | 53 +++ tests/unit/agents/email/test_init_endpoint.py | 302 ++++++++++++++++++ tests/unit/agents/email/test_spec_html.py | 9 + 8 files changed, 754 insertions(+), 12 deletions(-) create mode 100644 tests/unit/agents/email/test_init_endpoint.py 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 63a2f9415..b6fe3912e 100644 --- a/hub/agents/python/email/gaia_agent_email/api_routes.py +++ b/hub/agents/python/email/gaia_agent_email/api_routes.py @@ -48,9 +48,9 @@ import re import secrets import threading -from typing import Any, List, Literal, Optional +from typing import Any, List, Literal, Optional, Tuple -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Response from fastapi.responses import HTMLResponse from gaia_agent_email.contract import ( ActionItem, @@ -130,6 +130,93 @@ _LEMONADE_PROBE_READ_TIMEOUT = 3.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_reachable(base_url: Optional[str] = None) -> Tuple[bool, str]: + """Probe Lemonade's ``/health`` with a short connect timeout (#1677). + + Returns ``(reachable, probe_base)``. 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). Never raises: the readiness endpoint + reports the boolean rather than failing. + """ + import requests + + probe_base = _resolve_probe_base(base_url) + try: + requests.get( + f"{probe_base}/health", + timeout=(_LEMONADE_PROBE_CONNECT_TIMEOUT, _LEMONADE_PROBE_READ_TIMEOUT), + ) + return True, probe_base + except requests.exceptions.RequestException: + return False, 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 _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 _split_sentences(text: str) -> List[str]: text = re.sub(r"\s+", " ", (text or "").strip()) if not text: @@ -257,14 +344,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 +881,57 @@ class VersionResponse(_Strict): agentVersion: str = Field(..., description="Package build version.") +class InitLemonadeStatus(_Strict): + """Reachability of the local Lemonade Server the triage path depends on.""" + + reachable: bool = Field( + ..., description="True when Lemonade answered the /health probe." + ) + base_url: str = Field(..., description="The /api/v1 base URL that was probed.") + + +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 +1114,86 @@ 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, 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. + """ + import requests + + model_id = _resolve_email_model_id() + reachable, probe_base = _probe_lemonade_reachable(base_url) + lemonade = InitLemonadeStatus(reachable=reachable, base_url=probe_base) + + 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." + ), + ) + + if not present: + return InitResponse( + ready=False, + lemonade=lemonade, + model=InitModelStatus(id=model_id, present=False, loadable=None), + hint=( + f"Model `{model_id}` not downloaded — run `gaia init` (or pull it " + "via Lemonade), then retry." + ), + ) + + return InitResponse( + ready=True, + lemonade=lemonade, + model=InitModelStatus(id=model_id, present=True, loadable=None), + 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 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 + + @router.get("/spec", response_class=HTMLResponse, include_in_schema=False) async def email_spec() -> HTMLResponse: """Serve the self-contained HTML endpoint spec page. @@ -1006,6 +1218,9 @@ async def email_spec() -> 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 af8fdc533..ed1d9371d 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 @@ -360,6 +359,9 @@ def render_endpoint_spec_html() -> str: EmailDraftResponse, EmailSendRequest, EmailSendResponse, + InitLemonadeStatus, + InitModelStatus, + InitResponse, ) draft_block = _endpoint_block( @@ -386,6 +388,26 @@ 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 and confirms the triage model is " + f"downloaded — so a host can verify “ready to triage,” not " + f"just “process up.” Read-only: probes only, no model pull.

" + f"

Response body

" + f"{_model_table(InitResponse, 'InitResponse')}" + f"{_model_table(InitLemonadeStatus, 'InitLemonadeStatus')}" + f"{_model_table(InitModelStatus, 'InitModelStatus')}" + f"
" + ) + body = f""" @@ -415,6 +437,8 @@ def render_endpoint_spec_html() -> str: {send_block} +{init_block} + diff --git a/hub/agents/python/email/openapi.email.json b/hub/agents/python/email/openapi.email.json index b4256c724..357c0425f 100644 --- a/hub/agents/python/email/openapi.email.json +++ b/hub/agents/python/email/openapi.email.json @@ -560,6 +560,100 @@ "title": "HealthResponse", "type": "object" }, + "InitLemonadeStatus": { + "additionalProperties": false, + "description": "Reachability of the local Lemonade Server the triage path depends on.", + "properties": { + "base_url": { + "description": "The /api/v1 base URL that was probed.", + "title": "Base Url", + "type": "string" + }, + "reachable": { + "description": "True when Lemonade answered the /health probe.", + "title": "Reachable", + "type": "boolean" + } + }, + "required": [ + "reachable", + "base_url" + ], + "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 +932,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 and confirms the triage model is downloaded, so a host\ncan verify \"ready to triage,\" not just \"process up.\" Read-only — no model\npull 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..b44fe6c3a 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -1292,6 +1292,18 @@

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 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 and the triage model is present.
lemonadeobject{ reachable: bool, base_url: string } — the /api/v1 base that was probed.
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 (what failed / what to do); null when ready.
+
+
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 +1369,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/tests/test_email_openapi_conformance.py b/tests/test_email_openapi_conformance.py index 2cad650b0..1e5efb924 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_reachable", + return_value=(False, "http://localhost:8000/api/v1"), + ): + 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_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + 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/unit/agents/email/test_init_endpoint.py b/tests/unit/agents/email/test_init_endpoint.py new file mode 100644 index 000000000..17613f075 --- /dev/null +++ b/tests/unit/agents/email/test_init_endpoint.py @@ -0,0 +1,302 @@ +# 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_reachable, + _probe_model_present, + _resolve_email_model_id, + _resolve_probe_base, +) +from gaia_agent_email.export_openapi import build_app # 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" + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +def test_init_ready_returns_200(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), + ): + 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": "http://localhost:8000/api/v1", + } + 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_reachable", + return_value=(False, "http://localhost:8000/api/v1"), + ): + 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_model_missing_returns_503_with_model_hint(client): + with ( + patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + 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["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.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"), + ), + ): + 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.object( + ar, + "_probe_lemonade_reachable", + return_value=(True, "http://localhost:8000/api/v1"), + ), + 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"} + assert set(body["model"]) == {"id", "present", "loadable"} + + +# --------------------------------------------------------------------------- +# 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() + paths = {route.path for route in app.routes} + assert "/v1/email/init" in paths diff --git a/tests/unit/agents/email/test_spec_html.py b/tests/unit/agents/email/test_spec_html.py index facb78690..38a49eaef 100644 --- a/tests/unit/agents/email/test_spec_html.py +++ b/tests/unit/agents/email/test_spec_html.py @@ -80,6 +80,15 @@ 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 + + # --------------------------------------------------------------------------- # Contract field names — sourced from the models so a contract change that # drops a field will break this test, not slip through silently. From 2995d77544494010f139959a98677e71740fbb10 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Mon, 22 Jun 2026 09:50:03 -0700 Subject: [PATCH 08/12] test(agent-email): handle mounted sub-routers in init mount test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_app()'s app.routes mixes APIRoutes with mounted _IncludedRouter objects, which have no .path attribute — iterating it raised AttributeError. Read .path defensively and additionally prove the route is reachable through the sidecar app with a real request (503, not 404). --- tests/unit/agents/email/test_init_endpoint.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/unit/agents/email/test_init_endpoint.py b/tests/unit/agents/email/test_init_endpoint.py index 17613f075..7157897b0 100644 --- a/tests/unit/agents/email/test_init_endpoint.py +++ b/tests/unit/agents/email/test_init_endpoint.py @@ -298,5 +298,19 @@ def test_init_route_mounted_via_packaging_server(): spec.loader.exec_module(module) app = module.build_app() - paths = {route.path for route in app.routes} + # ``app.routes`` mixes APIRoutes (which have ``.path``) with mounted + # sub-routers (``_IncludedRouter``, which do not), so read ``.path`` + # defensively rather than assuming every route exposes it. + paths = {getattr(route, "path", None) for route in app.routes} assert "/v1/email/init" in paths + + # Strongest proof it's actually reachable through the sidecar app: a real + # request returns the readiness status (503 here — Lemonade is down in the + # test env), not a 404. Probe patched so the test never hits the network. + with patch.object( + ar, + "_probe_lemonade_reachable", + return_value=(False, "http://localhost:8000/api/v1"), + ): + resp = TestClient(app).get("/v1/email/init") + assert resp.status_code == 503 From 11da149744a1545c6c14560a8cf7bd3932249789 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Mon, 22 Jun 2026 09:53:28 -0700 Subject: [PATCH 09/12] test(agent-email): prove init mount via HTTP, not route introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer FastAPI keeps included routes under a mounted sub-router rather than flattening them into app.routes, so a .path scan can't find /v1/email/init even though it is served. Assert reachability with a real request (503, not 404) — version-robust proof the sidecar app mounts it. --- tests/unit/agents/email/test_init_endpoint.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/unit/agents/email/test_init_endpoint.py b/tests/unit/agents/email/test_init_endpoint.py index 7157897b0..24a2606ea 100644 --- a/tests/unit/agents/email/test_init_endpoint.py +++ b/tests/unit/agents/email/test_init_endpoint.py @@ -298,19 +298,19 @@ def test_init_route_mounted_via_packaging_server(): spec.loader.exec_module(module) app = module.build_app() - # ``app.routes`` mixes APIRoutes (which have ``.path``) with mounted - # sub-routers (``_IncludedRouter``, which do not), so read ``.path`` - # defensively rather than assuming every route exposes it. - paths = {getattr(route, "path", None) for route in app.routes} - assert "/v1/email/init" in paths - - # Strongest proof it's actually reachable through the sidecar app: a real - # request returns the readiness status (503 here — Lemonade is down in the - # test env), not a 404. Probe patched so the test never hits the network. + # 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_reachable", return_value=(False, "http://localhost:8000/api/v1"), ): 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 From 50d8649135f617767c0e62c4c3abc0d1aeb2470a Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Mon, 22 Jun 2026 11:01:48 -0700 Subject: [PATCH 10/12] feat(agent-email): add POST /v1/email/init streaming provisioning verb (#1795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/email/init tells you the triage stack isn't ready, but a frozen-binary sidecar had no way to *fix* it. POST /v1/email/init is the provisioning companion: it tells a running local Lemonade to download the configured email model and streams newline-delimited (text/plain) progress so a consumer (the #1814 playground) can render it terminal-style, line by line. A ✓-prefixed final line means success, ✗ means failure. Scope is the frozen-binary reality: the sidecar can't run the full `gaia init` or install Lemonade itself (chicken-and-egg). If Lemonade is unreachable the verb returns a real 503 with an actionable line and pulls nothing; once a pull starts the response is a committed 200 (HTTP status can't change mid-stream), so the trailing ✓/✗ line is the authoritative outcome. The pull posts only `model_name` (no `recipe`) for the built-in email model — the #1655 trap. GET behavior is unchanged. POST is a streaming operational verb (like GET /spec), so it's kept out of the JSON OpenAPI and documented in the HTML spec (spec_html.py + specification.html) instead. --- .../email/gaia_agent_email/api_routes.py | 160 ++++++++++++++++- .../email/gaia_agent_email/spec_html.py | 26 +++ hub/agents/python/email/specification.html | 6 + tests/unit/agents/email/test_init_endpoint.py | 165 ++++++++++++++++++ tests/unit/agents/email/test_spec_html.py | 8 + 5 files changed, 363 insertions(+), 2 deletions(-) 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 b6fe3912e..5a4e97f3f 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, Tuple +from typing import Any, Iterator, List, Literal, Optional, Tuple from fastapi import APIRouter, HTTPException, Response -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, StreamingResponse from gaia_agent_email.contract import ( ActionItem, DraftReply, @@ -129,6 +129,11 @@ _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. @@ -203,6 +208,36 @@ def _probe_model_present(probe_base: str, model_id: str) -> bool: 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. @@ -1194,6 +1229,127 @@ async def email_init(response: Response) -> InitResponse: 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. 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 ed1d9371d..119d0fdd2 100644 --- a/hub/agents/python/email/gaia_agent_email/spec_html.py +++ b/hub/agents/python/email/gaia_agent_email/spec_html.py @@ -408,6 +408,30 @@ def render_endpoint_spec_html() -> str: 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""" @@ -439,6 +463,8 @@ def render_endpoint_spec_html() -> str: {init_block} +{provision_block} + diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html index b44fe6c3a..a6825bd04 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -1304,6 +1304,12 @@

Response body — InitResponse

+
+ 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.

diff --git a/tests/unit/agents/email/test_init_endpoint.py b/tests/unit/agents/email/test_init_endpoint.py index 24a2606ea..925a19e79 100644 --- a/tests/unit/agents/email/test_init_endpoint.py +++ b/tests/unit/agents/email/test_init_endpoint.py @@ -32,6 +32,7 @@ from gaia_agent_email.api_routes import ( # noqa: E402 _probe_lemonade_reachable, _probe_model_present, + _pull_model, _resolve_email_model_id, _resolve_probe_base, ) @@ -314,3 +315,167 @@ def test_init_route_mounted_via_packaging_server(): 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_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.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 38a49eaef..166ac1595 100644 --- a/tests/unit/agents/email/test_spec_html.py +++ b/tests/unit/agents/email/test_spec_html.py @@ -89,6 +89,14 @@ def test_init_endpoint_present(): 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. From 5ac76d808df24da30f630e52cb8dcfad139787c7 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Mon, 22 Jun 2026 11:22:26 -0700 Subject: [PATCH 11/12] feat(agent-email): readiness checks Lemonade VERSION compat, not just presence (#1795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/email/init said "ready" as long as Lemonade was up and the model was downloaded — even against a Lemonade too old to run the triage stack, which then fails at request time. Readiness now also checks the server VERSION: it reads Lemonade's self-reported version from /health and compares it to the agent's required minimum, so "ready" means "ready to triage," version included. The lemonade block gains found-vs-required fields the playground renders: lemonade: { reachable, base_url, version, min_version, compatible } A too-old server → ready=false (503) with an actionable upgrade hint ("Lemonade x.y.z is older than the required a.b.c — upgrade …"). An unadvertised/unparseable version is reported compatible=null and does NOT block (mirrors gaia init's don't-block-on-unparseable policy). Single source of truth: min_lemonade_version lives in gaia-agent.yaml (the manifest `gaia init` reads) AND as gaia_agent_email.version.MIN_LEMONADE_VERSION (the RUNTIME value — the frozen sidecar bundles neither gaia.installer nor the yaml, so the check can't read them at run time). A lock-step test fails if the two drift. The version-parse helper mirrors InitCommand._parse_version locally for the same frozen-binary reason. /health stays liveness-only; POST /v1/email/init (provisioning) is unchanged. --- hub/agents/python/email/gaia-agent.yaml | 5 + .../email/gaia_agent_email/api_routes.py | 152 +++++++++++-- .../email/gaia_agent_email/spec_html.py | 8 +- .../python/email/gaia_agent_email/version.py | 13 +- hub/agents/python/email/openapi.email.json | 36 +++- hub/agents/python/email/specification.html | 8 +- tests/test_email_openapi_conformance.py | 8 +- tests/unit/agents/email/test_init_endpoint.py | 199 +++++++++++++++--- 8 files changed, 362 insertions(+), 67 deletions(-) diff --git a/hub/agents/python/email/gaia-agent.yaml b/hub/agents/python/email/gaia-agent.yaml index 5ec431577..6833dff6b 100644 --- a/hub/agents/python/email/gaia-agent.yaml +++ b/hub/agents/python/email/gaia-agent.yaml @@ -22,6 +22,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 5a4e97f3f..c711ecade 100644 --- a/hub/agents/python/email/gaia_agent_email/api_routes.py +++ b/hub/agents/python/email/gaia_agent_email/api_routes.py @@ -154,26 +154,48 @@ def _resolve_probe_base(base_url: Optional[str]) -> str: return probe_base -def _probe_lemonade_reachable(base_url: Optional[str] = None) -> Tuple[bool, str]: +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)``. 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). Never raises: the readiness endpoint - reports the boolean rather than failing. + 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: - requests.get( + resp = requests.get( f"{probe_base}/health", timeout=(_LEMONADE_PROBE_CONNECT_TIMEOUT, _LEMONADE_PROBE_READ_TIMEOUT), ) - return True, probe_base except requests.exceptions.RequestException: - return False, probe_base + 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: @@ -252,6 +274,40 @@ def _resolve_email_model_id() -> str: 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()) if not text: @@ -917,12 +973,33 @@ class VersionResponse(_Strict): class InitLemonadeStatus(_Strict): - """Reachability of the local Lemonade Server the triage path depends on.""" + """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): @@ -1152,16 +1229,32 @@ async def email_version() -> VersionResponse: 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, 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. + 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 = _probe_lemonade_reachable(base_url) - lemonade = InitLemonadeStatus(reachable=reachable, base_url=probe_base) + 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( @@ -1190,11 +1283,27 @@ def _compute_init_status(base_url: Optional[str] = None) -> InitResponse: ), ) + 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=InitModelStatus(id=model_id, present=False, loadable=None), + model=model, hint=( f"Model `{model_id}` not downloaded — run `gaia init` (or pull it " "via Lemonade), then retry." @@ -1204,7 +1313,7 @@ def _compute_init_status(base_url: Optional[str] = None) -> InitResponse: return InitResponse( ready=True, lemonade=lemonade, - model=InitModelStatus(id=model_id, present=True, loadable=None), + model=model, hint=None, ) @@ -1219,9 +1328,10 @@ async def email_init(response: Response) -> InitResponse: 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 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. + 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: 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 119d0fdd2..63026917c 100644 --- a/hub/agents/python/email/gaia_agent_email/spec_html.py +++ b/hub/agents/python/email/gaia_agent_email/spec_html.py @@ -398,9 +398,11 @@ def render_endpoint_spec_html() -> str: 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 and confirms the triage model is " - f"downloaded — so a host can verify “ready to triage,” not " - f"just “process up.” Read-only: probes only, no model pull.

" + 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')}" diff --git a/hub/agents/python/email/gaia_agent_email/version.py b/hub/agents/python/email/gaia_agent_email/version.py index d90d83d94..5708b9243 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 357c0425f..5024174eb 100644 --- a/hub/agents/python/email/openapi.email.json +++ b/hub/agents/python/email/openapi.email.json @@ -562,22 +562,52 @@ }, "InitLemonadeStatus": { "additionalProperties": false, - "description": "Reachability of the local Lemonade Server the triage path depends on.", + "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" + "base_url", + "min_version" ], "title": "InitLemonadeStatus", "type": "object" @@ -934,7 +964,7 @@ }, "/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 and confirms the triage model is downloaded, so a host\ncan verify \"ready to triage,\" not just \"process up.\" Read-only — no model\npull or provisioning is triggered.", + "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": { diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html index a6825bd04..a378aae9b 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -1294,13 +1294,13 @@

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 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.

+

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 and the triage model is present.
lemonadeobject{ reachable: bool, base_url: string } — the /api/v1 base that was probed.
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 (what failed / what to do); null when ready.
hintstring | nullActionable next step when not ready (e.g. “Lemonade x.y.z < required a.b.c — upgrade”); null when ready.
diff --git a/tests/test_email_openapi_conformance.py b/tests/test_email_openapi_conformance.py index 1e5efb924..1b842f923 100644 --- a/tests/test_email_openapi_conformance.py +++ b/tests/test_email_openapi_conformance.py @@ -185,8 +185,8 @@ def test_init_conforms_to_spec_when_not_ready(client, committed_spec): so the body must carry every required key from the documented schema. """ with patch( - "gaia_agent_email.api_routes._probe_lemonade_reachable", - return_value=(False, "http://localhost:8000/api/v1"), + "gaia_agent_email.api_routes._probe_lemonade_health", + return_value=(False, "http://localhost:8000/api/v1", None), ): resp = client.get("/v1/email/init") @@ -204,8 +204,8 @@ 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_reachable", - return_value=(True, "http://localhost:8000/api/v1"), + "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", diff --git a/tests/unit/agents/email/test_init_endpoint.py b/tests/unit/agents/email/test_init_endpoint.py index 925a19e79..c650260ec 100644 --- a/tests/unit/agents/email/test_init_endpoint.py +++ b/tests/unit/agents/email/test_init_endpoint.py @@ -30,6 +30,7 @@ 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, @@ -37,6 +38,7 @@ _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 @@ -107,6 +109,38 @@ def test_unreachable_probe_returns_false_not_raises(): 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 # --------------------------------------------------------------------------- @@ -162,14 +196,20 @@ def test_resolve_email_model_id_defaults_to_agent_default(): # 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.object( - ar, - "_probe_lemonade_reachable", - return_value=(True, "http://localhost:8000/api/v1"), - ), + _patch_health(MIN_LEMONADE_VERSION), patch.object(ar, "_probe_model_present", return_value=True), ): resp = client.get("/v1/email/init") @@ -179,7 +219,10 @@ def test_init_ready_returns_200(client): assert body["ready"] is True assert body["lemonade"] == { "reachable": True, - "base_url": "http://localhost:8000/api/v1", + "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 @@ -189,11 +232,7 @@ def test_init_ready_returns_200(client): def test_init_lemonade_down_returns_503_with_actionable_hint(client): - with patch.object( - ar, - "_probe_lemonade_reachable", - return_value=(False, "http://localhost:8000/api/v1"), - ): + with patch.object(ar, "_probe_lemonade_health", return_value=(False, _BASE, None)): resp = client.get("/v1/email/init") assert resp.status_code == 503 @@ -207,13 +246,70 @@ def test_init_lemonade_down_returns_503_with_actionable_hint(client): 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.object( - ar, - "_probe_lemonade_reachable", - return_value=(True, "http://localhost:8000/api/v1"), - ), + _patch_health(MIN_LEMONADE_VERSION), patch.object(ar, "_probe_model_present", return_value=False), ): resp = client.get("/v1/email/init") @@ -222,6 +318,7 @@ def test_init_model_missing_returns_503_with_model_hint(client): 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"] @@ -232,11 +329,7 @@ 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.object( - ar, - "_probe_lemonade_reachable", - return_value=(True, "http://localhost:8000/api/v1"), - ), + _patch_health(MIN_LEMONADE_VERSION), patch.object( ar, "_probe_model_present", @@ -255,19 +348,63 @@ def test_init_response_forbids_unknown_fields(client): # _Strict response models — the serialized body carries exactly the # documented keys, no silent extras. with ( - patch.object( - ar, - "_probe_lemonade_reachable", - return_value=(True, "http://localhost:8000/api/v1"), - ), + _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"} + 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 # --------------------------------------------------------------------------- @@ -308,8 +445,8 @@ def test_init_route_mounted_via_packaging_server(): # test never hits the network. with patch.object( ar, - "_probe_lemonade_reachable", - return_value=(False, "http://localhost:8000/api/v1"), + "_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" @@ -467,8 +604,8 @@ def test_get_init_still_readiness_only_unchanged(client): with ( patch.object( ar, - "_probe_lemonade_reachable", - return_value=(True, "http://localhost:8000/api/v1"), + "_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, From 4941caa71b9e1dfdad7d8b12eef766be419b2457 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Mon, 22 Jun 2026 13:57:08 -0700 Subject: [PATCH 12/12] feat(cli): add gaia init --profile email (download + version-check the triage model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen email sidecar can't run the full installer, so `gaia init` is the host-side path that downloads and version-checks the email triage model. Adds an "email" init profile (Gemma-4-E4B-it-GGUF) and exposes `gaia init --profile email` as a CLI choice. Its min_lemonade_version is held in lock-step with the email agent's runtime MIN_LEMONADE_VERSION (the same minimum GET /v1/email/init enforces), so the installer and readiness can't disagree on what 'compatible' means — a test asserts they match. --- src/gaia/cli.py | 15 +++++++++++++-- src/gaia/installer/init_command.py | 12 ++++++++++++ tests/unit/test_init_command.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/gaia/cli.py b/src/gaia/cli.py index 73fe70f1f..cca6f1002 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/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."""