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