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 d952a0b2285e77f210072d42d8022a59026d0380 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 14:21:54 -0700 Subject: [PATCH 07/12] feat(rag): index Microsoft Word (.docx) documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word documents previously could not be indexed for RAG — the UI rejected .docx with a "not supported, save as PDF" message and the SDK had no extractor. Users with handbooks, contracts, and reports in .docx had to convert to PDF first. Now .docx indexes directly like PDF/PPTX/XLSX. Extraction walks the document body in order, capturing paragraph text, table cells (including tables nested in a cell), and — importantly for form/template docs — text inside content controls (w:sdt) and hyperlinks, which Word stores outside the direct runs that Paragraph.text exposes. Corrupt/non-.docx files and a missing python-docx install fail loudly with actionable errors. Allow-lists and rejection messaging across the UI backend and frontend are updated so .docx flows end-to-end. Closes #1072 --- docs/guides/chat.mdx | 2 +- docs/sdk/sdks/rag.mdx | 9 +- setup.py | 2 + .../src/components/UnsupportedFeature.tsx | 19 +- .../__tests__/UnsupportedFeature.test.tsx | 8 +- src/gaia/rag/sdk.py | 135 ++++++++ src/gaia/ui/routers/files.py | 14 +- src/gaia/ui/utils.py | 20 +- tests/unit/chat/ui/test_server.py | 24 +- tests/unit/rag/test_docx_extraction.py | 292 ++++++++++++++++++ 10 files changed, 489 insertions(+), 36 deletions(-) create mode 100644 tests/unit/rag/test_docx_extraction.py diff --git a/docs/guides/chat.mdx b/docs/guides/chat.mdx index 39fba0a90..17a3e21cd 100644 --- a/docs/guides/chat.mdx +++ b/docs/guides/chat.mdx @@ -102,7 +102,7 @@ gaia chat --query "Hello" --show-stats ## Document Q&A (RAG) -RAG (Retrieval-Augmented Generation) enables chatting with PDF and PowerPoint (.pptx) documents using semantic search and context retrieval. +RAG (Retrieval-Augmented Generation) enables chatting with documents — including PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) — using semantic search and context retrieval. ### CLI with RAG diff --git a/docs/sdk/sdks/rag.mdx b/docs/sdk/sdks/rag.mdx index 1f9fb37bd..801e1119e 100644 --- a/docs/sdk/sdks/rag.mdx +++ b/docs/sdk/sdks/rag.mdx @@ -49,6 +49,13 @@ RAG searches your documents and answers using real information. - **Augmented** → Add that information to the LLM's context - **Generation** → LLM generates an answer using your documents + +**Supported document formats:** PDF, Word (`.docx`), PowerPoint (`.pptx`), +Excel (`.xlsx`), plus plain text, Markdown, CSV, JSON, HTML, and common source +code files. Legacy binary Office formats (`.doc`, `.ppt`, `.xls`) are not +supported — re-save them as the modern `.docx` / `.pptx` / `.xlsx` formats. + + --- ## How RAG Works: A Mental Model @@ -62,7 +69,7 @@ This happens once when you index documents: ```mermaid %%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#E2A33E', 'primaryTextColor':'#1a1a1a', 'primaryBorderColor':'#A87B2D', 'lineColor':'#EFC480', 'secondaryColor':'#2d2d2d', 'tertiaryColor':'#f5f5f5', 'fontFamily': 'system-ui, -apple-system, sans-serif'}}}%% flowchart TD - A(["PDF / PPTX Document"]) --> B(["EXTRACT TEXT"]) + A(["PDF / DOCX / PPTX / XLSX Document"]) --> B(["EXTRACT TEXT"]) B --> C(["SPLIT INTO CHUNKS"]) C --> D(["GENERATE EMBEDDINGS"]) D --> E[("STORE IN FAISS")] diff --git a/setup.py b/setup.py index a4f00a3f3..dae35c081 100644 --- a/setup.py +++ b/setup.py @@ -148,6 +148,7 @@ "pymupdf>=1.24.0", "pypdf", "python-pptx>=0.6.21", + "python-docx>=1.1.0", "sentence-transformers", "safetensors", # torch is pinned lower-bound only. The "audio" extra caps @@ -234,6 +235,7 @@ "pymupdf>=1.24.0", "pypdf", "python-pptx>=0.6.21", + "python-docx>=1.1.0", "sentence-transformers", ], "lint": [ diff --git a/src/gaia/apps/webui/src/components/UnsupportedFeature.tsx b/src/gaia/apps/webui/src/components/UnsupportedFeature.tsx index 74c5f04d9..b6785f93a 100644 --- a/src/gaia/apps/webui/src/components/UnsupportedFeature.tsx +++ b/src/gaia/apps/webui/src/components/UnsupportedFeature.tsx @@ -84,17 +84,18 @@ const UNSUPPORTED_FILE_CATEGORIES: FileTypeCategory[] = [ }, { label: 'Microsoft Office', - extensions: new Set(['.doc', '.docx', '.ppt', '.xls']), + extensions: new Set(['.doc', '.ppt', '.xls']), message: - 'Word, legacy PowerPoint (.ppt), and legacy Excel (.xls) files are ' + - 'not yet supported — GAIA does not currently ship parsers for these ' + - 'formats. Modern PowerPoint (.pptx) is supported.', + 'Legacy Office formats (.doc, legacy PowerPoint .ppt, legacy Excel ' + + '.xls) are not supported — GAIA reads only the modern XML-based ' + + 'formats. Modern Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) ' + + 'are supported.', alternatives: [ + 'Re-save as .docx — GAIA indexes modern Word documents directly', 'Save modern PowerPoint as .pptx — GAIA indexes .pptx directly', - 'Save Word as PDF, then index the PDF', 'Re-save legacy .xls workbooks as .xlsx — GAIA supports modern Excel files', ], - featureTitle: 'Support Microsoft Office (doc, docx, ppt, xls) indexing', + featureTitle: 'Support legacy Microsoft Office (doc, ppt, xls) indexing', }, ]; @@ -117,11 +118,11 @@ export function getUnsupportedCategory(extension: string): FileTypeCategory | nu * ``src/gaia/ui/utils.py``. Only list extensions that have a real extractor * in ``src/gaia/rag/sdk.py::_extract_text_from_file`` — listing one without * a backend handler causes the RAG pipeline to index binary garbage. - * .pptx IS supported (python-pptx ships with GAIA); .doc/.docx/.ppt and - * legacy .xls are intentionally excluded. + * .pptx and .docx ARE supported (python-pptx / python-docx ship with GAIA); + * legacy .doc/.ppt/.xls are intentionally excluded. */ export const SUPPORTED_EXTENSIONS = new Set([ - '.pdf', '.pptx', '.txt', '.md', '.csv', '.json', '.xlsx', + '.pdf', '.pptx', '.docx', '.txt', '.md', '.csv', '.json', '.xlsx', '.html', '.htm', '.xml', '.svg', '.yaml', '.yml', '.py', '.js', '.ts', '.java', '.c', '.cpp', '.h', '.rs', '.go', '.rb', '.sh', '.bat', '.ps1', '.log', diff --git a/src/gaia/apps/webui/src/components/__tests__/UnsupportedFeature.test.tsx b/src/gaia/apps/webui/src/components/__tests__/UnsupportedFeature.test.tsx index f278109dd..465c00f38 100644 --- a/src/gaia/apps/webui/src/components/__tests__/UnsupportedFeature.test.tsx +++ b/src/gaia/apps/webui/src/components/__tests__/UnsupportedFeature.test.tsx @@ -32,8 +32,8 @@ describe('getUnsupportedCategory', () => { expect(getUnsupportedCategory('.pdf')).toBeNull(); }); - it('returns "Microsoft Office" for .docx', () => { - expect(getUnsupportedCategory('.docx')?.label).toBe('Microsoft Office'); + it('returns null for .docx — Word indexing is supported', () => { + expect(getUnsupportedCategory('.docx')).toBeNull(); }); it('returns null for .pptx — PowerPoint indexing is supported', () => { @@ -60,6 +60,10 @@ describe('isExtensionSupported', () => { expect(isExtensionSupported('.pptx')).toBe(true); }); + it('returns true for .docx — modern Word is indexable', () => { + expect(isExtensionSupported('.docx')).toBe(true); + }); + it('returns false for .exe', () => { expect(isExtensionSupported('.exe')).toBe(false); }); diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index 3ecafe902..59a864f01 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -1601,6 +1601,137 @@ def _extract_text_from_xlsx(self, xlsx_path: str) -> str: self.log.error(f"Error reading Excel file {xlsx_path}: {e}") raise + def _extract_text_from_docx(self, docx_path: str) -> str: + """Extract text from a Word (.docx) document using python-docx. + + Walks the document body in order so paragraphs and tables stay + interleaved. Paragraph text is collected from every ``w:t`` node so + runs nested in hyperlinks and **content controls** (``w:sdt`` — the + fields used by form/template documents) are captured, not just the + direct runs that ``Paragraph.text`` exposes. Table cells (including + tables nested inside a cell) and block-level content controls are + recursed into. + + Corrupt / non-.docx files and a missing ``python-docx`` install raise + actionable errors, mirroring :meth:`_extract_text_from_pptx` (.docx is + a ZIP container like .pptx). + + Known omissions: header/footer text (separate XML parts, usually + repeated boilerplate) and embedded images. + # TODO(#1072): VLM extraction for images embedded in .docx files. + + Returns: + The extracted text as a single string. + """ + file_name = Path(docx_path).name + + try: + from docx import Document # pylint: disable=import-outside-toplevel + from docx.oxml.ns import qn # pylint: disable=import-outside-toplevel + except ImportError as e: + raise ImportError( + "python-docx is required for Word document processing. " + "Install it with: uv pip install python-docx" + ) from e + + # Guard against zip bombs: .docx is a ZIP container. Check the total + # uncompressed size is sane before handing it to python-docx. + try: + with zipfile.ZipFile(docx_path, "r") as zf: + total_uncompressed = sum(info.file_size for info in zf.infolist()) + max_uncompressed = 500 * 1024 * 1024 # 500 MB + if total_uncompressed > max_uncompressed: + msg = ( + f"Word file too large after decompression: {file_name}\n" + f"Uncompressed size: {total_uncompressed / (1024*1024):.0f} MB " + f"(limit: {max_uncompressed / (1024*1024):.0f} MB)\n" + "The file may be a zip bomb or contain very large embedded media.\n" + "Suggestions:\n" + " 1. Remove unnecessary images/media to reduce file size\n" + " 2. Save as PDF and index the PDF instead" + ) + self.log.error( + f"DOCX zip bomb guard: {docx_path} " + f"({total_uncompressed} bytes uncompressed)" + ) + raise ValueError(msg) + except zipfile.BadZipFile as e: + msg = ( + f"Could not read Word file: {file_name}\n" + f"Reason: {e}\n" + "The file appears to be corrupted or not a valid .docx file.\n" + "Suggestions:\n" + " 1. Re-download or re-export the document\n" + " 2. Try opening the file in Word to confirm it is readable\n" + " 3. Save as PDF and index the PDF instead" + ) + self.log.error(f"Corrupted DOCX (bad zip): {docx_path}: {e}") + raise ValueError(msg) from e + + try: + doc = Document(docx_path) + except Exception as e: + msg = ( + f"Could not read Word file: {file_name}\n" + f"Reason: {e}\n" + "The file appears to be corrupted or not a valid .docx file.\n" + "Suggestions:\n" + " 1. Re-download or re-export the document\n" + " 2. Try opening the file in Word to confirm it is readable\n" + " 3. Save as PDF and index the PDF instead" + ) + self.log.error(f"Corrupted DOCX {docx_path}: {e}") + raise ValueError(msg) from e + + try: + w_p, w_tbl, w_sdt = qn("w:p"), qn("w:tbl"), qn("w:sdt") + w_sdt_content = qn("w:sdtContent") + w_t, w_tr, w_tc = qn("w:t"), qn("w:tr"), qn("w:tc") + + def _paragraph_text(p_elem): + # Join every ``w:t`` descendant — captures runs nested in + # hyperlinks and inline content controls that Paragraph.text + # (direct runs only) would silently drop. + return "".join(node.text or "" for node in p_elem.iter(w_t)).strip() + + def _emit(elem, parts): + # Recursively walk a block element in document order, appending + # text fragments. Unknown tags (section props, etc.) are skipped. + if elem.tag == w_p: + para_text = _paragraph_text(elem) + if para_text: + parts.append(para_text) + elif elem.tag == w_tbl: + for row in elem.findall(w_tr): + cells = [] + for cell in row.findall(w_tc): + cell_parts = [] + for cell_child in cell: + _emit(cell_child, cell_parts) + cells.append(" ".join(cell_parts).strip()) + if any(cells): + parts.append(" | ".join(cells)) + elif elem.tag == w_sdt: + # Block-level content control — descend into its content. + content = elem.find(w_sdt_content) + if content is not None: + for sdt_child in content: + _emit(sdt_child, parts) + + parts = [] + for child in doc.element.body: + _emit(child, parts) + + text = "\n".join(parts) + + if self.config.show_stats: + print(f" ✅ Loaded Word file ({len(text):,} chars)") + self.log.info(f"📄 Extracted {len(text):,} characters from Word file") + return text + except Exception as e: + self.log.error(f"Error reading Word file {docx_path}: {e}") + raise + def _extract_text_from_file(self, file_path: str) -> tuple: """ Extract text from file based on type. @@ -1646,6 +1777,10 @@ def _extract_text_from_file(self, file_path: str) -> tuple: elif file_type in [".xlsx", ".xls"]: return self._extract_text_from_xlsx(file_path), metadata + # Word files + elif file_type == ".docx": + return self._extract_text_from_docx(file_path), metadata + # Code files (treat as text for Q&A purposes) elif file_type in [ # Backend languages diff --git a/src/gaia/ui/routers/files.py b/src/gaia/ui/routers/files.py index 503387c85..ba147e062 100644 --- a/src/gaia/ui/routers/files.py +++ b/src/gaia/ui/routers/files.py @@ -70,10 +70,10 @@ async def upload_file(file: UploadFile = File(...)): Constraints: - Maximum file size: 20 MB - Allowed types: common images (png, jpg, jpeg, gif, webp, bmp, svg) - and the document types listed in ALLOWED_EXTENSIONS (pdf, txt, md, - csv, json, xlsx, html, xml, yaml, and code files). Legacy Office - formats (.doc/.docx/.ppt/.xls) are NOT allowed — GAIA does - not currently ship extractors for them. + and the document types listed in ALLOWED_EXTENSIONS (pdf, pptx, docx, + txt, md, csv, json, xlsx, html, xml, yaml, and code files). Legacy Office + formats (.doc/.ppt/.xls) are NOT allowed — GAIA reads only the modern + XML-based Office formats. Returns: FileUploadResponse with the saved filename, URL, size, and metadata. @@ -91,9 +91,9 @@ async def upload_file(file: UploadFile = File(...)): detail=( f"File type '{ext}' is not allowed. " f"Supported types: images (png, jpg, jpeg, gif, webp, bmp, svg) " - f"and documents (pdf, pptx, txt, md, csv, json, xlsx, html, code files, etc.). " - f"Microsoft Word (.doc/.docx), legacy PowerPoint (.ppt), and legacy Excel (.xls) " - f"are not yet supported — save as PDF, .pptx, or .xlsx." + f"and documents (pdf, pptx, docx, txt, md, csv, json, xlsx, html, code files, etc.). " + f"Legacy Office formats (.doc, .ppt, .xls) are not supported — " + f"re-save as .docx, .pptx, or .xlsx." ), ) diff --git a/src/gaia/ui/utils.py b/src/gaia/ui/utils.py index 08401ac98..5cab4482a 100644 --- a/src/gaia/ui/utils.py +++ b/src/gaia/ui/utils.py @@ -46,15 +46,17 @@ # etc.) and poisons retrieval quality. # # Notable intentional exclusions (no parser ships with GAIA today): -# .doc/.docx/.ppt — need python-docx / python-pptx (legacy .ppt) +# .doc/.ppt — legacy binary formats; python-docx/python-pptx read only the +# modern XML formats (.docx/.pptx) # .xls (legacy BIFF) — openpyxl only handles .xlsx; would raise on open -# NOTE: .pptx IS supported — python-pptx ships with GAIA since v0.21. -# See ``_UNSUPPORTED_CATEGORIES`` below for the user-facing rejection hint. +# NOTE: .pptx and .docx ARE supported — python-pptx and python-docx ship with +# GAIA. See ``_UNSUPPORTED_CATEGORIES`` below for the user-facing rejection hint. # Image formats are tracked by issue #730 (VLM-based indexing). ALLOWED_EXTENSIONS = frozenset( { ".pdf", ".pptx", + ".docx", ".txt", ".md", ".csv", @@ -292,12 +294,12 @@ def sanitize_document_path(user_path: str) -> Path: "Tip: Export your data to CSV or JSON format, then index those files.", ), "office": ( - {".doc", ".docx", ".ppt", ".xls"}, - "Microsoft Word, legacy PowerPoint (.ppt), and legacy Excel (.xls) files " - "are not yet supported — GAIA does not currently ship the " - "parsers needed to extract text from these formats. " - "Tip: Save as PDF from Word, or re-save .xls as " - ".xlsx — GAIA supports PDFs, PPTX, and modern .xlsx workbooks.", + {".doc", ".ppt", ".xls"}, + "Legacy Microsoft Office formats (.doc, legacy PowerPoint .ppt, " + "and legacy Excel .xls) are not supported — GAIA reads only the " + "modern XML-based Office formats. " + "Tip: Re-save as .docx, .pptx, or .xlsx — GAIA supports PDFs, " + "modern Word (.docx), PowerPoint (.pptx), and Excel (.xlsx).", ), } diff --git a/tests/unit/chat/ui/test_server.py b/tests/unit/chat/ui/test_server.py index 2c88a5984..74d3712fd 100644 --- a/tests/unit/chat/ui/test_server.py +++ b/tests/unit/chat/ui/test_server.py @@ -1467,19 +1467,29 @@ def test_allows_document_extensions(self): # Only list extensions that have real extractors in # src/gaia/rag/sdk.py::_extract_text_from_file. See # ``ALLOWED_EXTENSIONS`` in src/gaia/ui/utils.py for the canonical list. - for ext in [".pdf", ".txt", ".md", ".csv", ".json", ".xlsx", ".yaml"]: + for ext in [ + ".pdf", + ".pptx", + ".docx", + ".txt", + ".md", + ".csv", + ".json", + ".xlsx", + ".yaml", + ]: # Should not raise _validate_file_path(Path(f"/home/user/file{ext}").resolve()) def test_rejects_legacy_office_extensions(self): - """Office formats without extractors must be rejected, not silently - indexed as binary garbage. Regression test for the allowlist cleanup - that removed .doc/.docx/.ppt/.xls — GAIA does not currently - ship python-docx/xlrd so these would produce garbage. - NOTE: .pptx IS supported (python-pptx ships with GAIA).""" + """Legacy binary Office formats must be rejected, not silently indexed + as binary garbage. Regression test for the allowlist — GAIA ships + python-docx/python-pptx (modern .docx/.pptx) but no parser for the + legacy BIFF/binary .doc/.ppt/.xls, so those would produce garbage. + NOTE: .pptx and .docx ARE supported.""" from pathlib import Path - for ext in [".doc", ".docx", ".ppt", ".xls"]: + for ext in [".doc", ".ppt", ".xls"]: with pytest.raises(Exception) as exc_info: _validate_file_path(Path(f"/home/user/file{ext}").resolve()) assert exc_info.value.status_code == 400 diff --git a/tests/unit/rag/test_docx_extraction.py b/tests/unit/rag/test_docx_extraction.py new file mode 100644 index 000000000..6dfce4ecd --- /dev/null +++ b/tests/unit/rag/test_docx_extraction.py @@ -0,0 +1,292 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Unit tests for Word (.docx) extraction in gaia.rag.sdk. + +Covers: +- Paragraph text extraction +- Table cell text extraction (tables must not be dropped) +- Document-order interleaving of paragraphs and tables +- Routing through the _extract_text_from_file dispatcher +- Actionable errors for corrupted / non-.docx files + +Fixtures are built programmatically with python-docx so the tests remain +hermetic and don't require committing binary fixture files to the repo. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +docx = pytest.importorskip("docx") + +from docx import Document # noqa: E402 +from docx.oxml import parse_xml # noqa: E402 +from docx.oxml.ns import nsdecls # noqa: E402 + +from gaia.rag.sdk import RAGSDK, RAGConfig # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def rag(tmp_path: Path) -> RAGSDK: + """A RAGSDK instance scoped to tmp_path with heavy ML deps stubbed out. + + Same pattern as test_pptx_extraction.py — stubs sentence-transformers, + faiss, VLM, and chat/LLM initialization. + """ + config = RAGConfig( + cache_dir=str(tmp_path / ".gaia"), + show_stats=False, + use_local_llm=False, + ) + + fake_vlm = MagicMock(name="VLMClient") + fake_vlm.check_availability.return_value = False + + with ( + patch.object(RAGSDK, "_check_dependencies", return_value=None), + patch("gaia.rag.sdk.AgentSDK", autospec=True) as mock_agent_sdk, + patch("gaia.llm.VLMClient", return_value=fake_vlm), + ): + mock_agent_sdk.return_value = MagicMock(name="AgentSDK") + instance = RAGSDK(config=config) + + yield instance + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_docx(path: Path, paragraphs: list, table: list | None = None) -> None: + """Create a test .docx programmatically. + + *paragraphs* is a list of paragraph strings. *table*, if given, is a + list of rows (each a list of cell strings); the table is appended after + the paragraphs. + """ + doc = Document() + for para in paragraphs: + doc.add_paragraph(para) + + if table: + num_rows = len(table) + num_cols = len(table[0]) if table else 0 + tbl = doc.add_table(rows=num_rows, cols=num_cols) + for r_idx, row in enumerate(table): + for c_idx, cell_text in enumerate(row): + tbl.cell(r_idx, c_idx).text = cell_text + + doc.save(str(path)) + + +# --------------------------------------------------------------------------- +# Tests — Text Extraction +# --------------------------------------------------------------------------- + + +class TestDocxTextExtraction: + """Tests for _extract_text_from_docx text handling.""" + + def test_basic_paragraph_extraction(self, rag, tmp_path): + """Paragraph text is extracted.""" + docx_path = tmp_path / "basic.docx" + _create_docx(docx_path, ["Hello World", "This is the second paragraph."]) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Hello World" in text + assert "This is the second paragraph." in text + + def test_table_cells_extracted(self, rag, tmp_path): + """Table cell text is extracted — tables must not be dropped.""" + docx_path = tmp_path / "table.docx" + _create_docx( + docx_path, + ["Intro paragraph"], + table=[ + ["Name", "Age", "City"], + ["Alice", "30", "Boston"], + ["Bob", "25", "Denver"], + ], + ) + + text = rag._extract_text_from_docx(str(docx_path)) + + # Paragraph and every table cell appear + assert "Intro paragraph" in text + for cell in ("Name", "Age", "City", "Alice", "30", "Boston", "Bob", "Denver"): + assert cell in text + + def test_document_order_preserved(self, rag, tmp_path): + """Paragraphs and tables stay in document order (table interleaved).""" + docx_path = tmp_path / "order.docx" + _create_docx( + docx_path, + ["BEFORE_TABLE"], + table=[["CELL_VALUE"]], + ) + # Append a paragraph after the table by re-opening + doc = Document(str(docx_path)) + doc.add_paragraph("AFTER_TABLE") + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert text.index("BEFORE_TABLE") < text.index("CELL_VALUE") + assert text.index("CELL_VALUE") < text.index("AFTER_TABLE") + + def test_empty_paragraphs_skipped(self, rag, tmp_path): + """Blank paragraphs do not add empty lines/noise.""" + docx_path = tmp_path / "blanks.docx" + _create_docx(docx_path, ["Real content", "", " ", "More content"]) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Real content" in text + assert "More content" in text + # No run of blank lines + assert "\n\n\n" not in text + + +# --------------------------------------------------------------------------- +# Tests — Rich content (content controls, nested tables, hyperlinks) +# --------------------------------------------------------------------------- + + +class TestDocxRichContent: + """Form/template documents put real data in content controls and nested + tables — extracting them is the difference between indexing the labels + and indexing the answers.""" + + def test_inline_content_control_captured(self, rag, tmp_path): + """Text in an inline content control (w:sdt) is captured, not dropped. + + Paragraph.text only sees direct runs; a filled form field lives in a + nested w:sdt run that would otherwise be silently lost. + """ + doc = Document() + para = doc.add_paragraph("Name: ") + sdt = parse_xml( + f"John Smith" + f"" + ) + para._p.append(sdt) + docx_path = tmp_path / "inline_cc.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Name:" in text + assert "John Smith" in text + + def test_block_content_control_captured(self, rag, tmp_path): + """A block-level content control's paragraphs are recursed into.""" + doc = Document() + doc.add_paragraph("Before") + sdt = parse_xml( + f"" + f"Inside content control" + f"" + ) + doc.element.body.append(sdt) + docx_path = tmp_path / "block_cc.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Before" in text + assert "Inside content control" in text + + def test_nested_table_in_cell_captured(self, rag, tmp_path): + """A table nested inside a table cell is not dropped.""" + doc = Document() + outer = doc.add_table(rows=1, cols=1) + cell = outer.cell(0, 0) + cell.text = "Outer cell" + nested = cell.add_table(rows=1, cols=2) + nested.cell(0, 0).text = "NestedA" + nested.cell(0, 1).text = "NestedB" + docx_path = tmp_path / "nested_table.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Outer cell" in text + assert "NestedA" in text + assert "NestedB" in text + + def test_hyperlink_text_captured(self, rag, tmp_path): + """Hyperlink anchor text (a w:hyperlink-wrapped run) is captured.""" + doc = Document() + para = doc.add_paragraph("See ") + link = parse_xml( + f'' + f"the documentation" + ) + para._p.append(link) + docx_path = tmp_path / "link.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "the documentation" in text + + +# --------------------------------------------------------------------------- +# Tests — Edge Cases / Errors +# --------------------------------------------------------------------------- + + +class TestDocxErrors: + """Tests for corrupted / invalid .docx handling.""" + + def test_corrupted_file_raises(self, rag, tmp_path): + """Garbage bytes with a .docx extension raise an actionable ValueError.""" + docx_path = tmp_path / "corrupt.docx" + docx_path.write_bytes(b"this is not a docx file at all") + + with pytest.raises(ValueError, match="corrupted|not a valid"): + rag._extract_text_from_docx(str(docx_path)) + + def test_error_names_the_file(self, rag, tmp_path): + """The actionable error names the offending file.""" + docx_path = tmp_path / "broken_report.docx" + docx_path.write_bytes(b"PK\x03\x04 not really a zip body") + + with pytest.raises(ValueError, match="broken_report.docx"): + rag._extract_text_from_docx(str(docx_path)) + + +# --------------------------------------------------------------------------- +# Tests — Dispatcher integration +# --------------------------------------------------------------------------- + + +class TestDocxDispatcher: + """Tests for .docx routing through _extract_text_from_file.""" + + def test_docx_dispatches_correctly(self, rag, tmp_path): + """_extract_text_from_file routes .docx to _extract_text_from_docx.""" + docx_path = tmp_path / "dispatch.docx" + _create_docx( + docx_path, + ["Dispatch Test"], + table=[["Works", "Fine"]], + ) + + text, metadata = rag._extract_text_from_file(str(docx_path)) + + assert "Dispatch Test" in text + assert "Works" in text + assert "Fine" in text + # Word has no page concept exposed by python-docx + assert metadata["num_pages"] is None From 5dc899a5df43feda75feb87c2288e7545e226a0c Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 14:48:27 -0700 Subject: [PATCH 08/12] fix(rag): harden .docx extraction against retrieval-poisoning edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the XML-walk extractor surfaced three cases that silently degraded exactly the form/template/report documents the feature targets: - Textboxes/shapes (mc:AlternateContent) were emitted twice — once from the DrawingML mc:Choice and once from the VML mc:Fallback twin — and glued onto the host paragraph. Skip mc:Fallback so shape text is captured once. - Tabs and line/page breaks (w:tab/w:br/w:cr) were dropped, gluing adjacent words into unsearchable tokens (e.g. "Column1Column2"). Translate them to whitespace while leaving intra-word run splits untouched. - Rows/cells wrapped in repeating-section content controls (w:sdt around w:tr/w:tc) were skipped by the direct-child findall. Descend through the wrappers. Also wrap missing-file / directory / permission OSErrors in the same actionable message as the corrupt-file path instead of a raw traceback. Adds regression tests for each case (textbox single-capture, tab/break whitespace, intra-word integrity, sdt-wrapped rows). --- src/gaia/rag/sdk.py | 77 +++++++++++++++++---- tests/unit/rag/test_docx_extraction.py | 94 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index 59a864f01..9b49da943 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -1605,12 +1605,15 @@ def _extract_text_from_docx(self, docx_path: str) -> str: """Extract text from a Word (.docx) document using python-docx. Walks the document body in order so paragraphs and tables stay - interleaved. Paragraph text is collected from every ``w:t`` node so - runs nested in hyperlinks and **content controls** (``w:sdt`` — the - fields used by form/template documents) are captured, not just the - direct runs that ``Paragraph.text`` exposes. Table cells (including - tables nested inside a cell) and block-level content controls are - recursed into. + interleaved. Paragraph text is collected by walking the inline tree, + so runs nested in hyperlinks, **content controls** (``w:sdt`` — the + fields used by form/template documents), and textboxes are captured — + not just the direct runs that ``Paragraph.text`` exposes. Tabs and + line/page breaks become whitespace so adjacent words don't glue + together, and the ``mc:Fallback`` (VML) twin of a DrawingML textbox is + skipped so shape text isn't emitted twice. Table cells (including + tables nested in a cell, and rows/cells wrapped in repeating-section + content controls) and block-level content controls are recursed into. Corrupt / non-.docx files and a missing ``python-docx`` install raise actionable errors, mirroring :meth:`_extract_text_from_pptx` (.docx is @@ -1667,6 +1670,16 @@ def _extract_text_from_docx(self, docx_path: str) -> str: ) self.log.error(f"Corrupted DOCX (bad zip): {docx_path}: {e}") raise ValueError(msg) from e + except OSError as e: + # Missing file, a directory, or an unreadable path — surface an + # actionable error instead of a raw OSError traceback. + msg = ( + f"Could not read Word file: {file_name}\n" + f"Reason: {e}\n" + "Check that the path exists and points to a readable .docx file." + ) + self.log.error(f"Cannot open DOCX {docx_path}: {e}") + raise ValueError(msg) from e try: doc = Document(docx_path) @@ -1686,13 +1699,51 @@ def _extract_text_from_docx(self, docx_path: str) -> str: try: w_p, w_tbl, w_sdt = qn("w:p"), qn("w:tbl"), qn("w:sdt") w_sdt_content = qn("w:sdtContent") - w_t, w_tr, w_tc = qn("w:t"), qn("w:tr"), qn("w:tc") + w_tr, w_tc = qn("w:tr"), qn("w:tc") + w_t, w_tab = qn("w:t"), qn("w:tab") + w_br, w_cr = qn("w:br"), qn("w:cr") + # ``mc`` (markup-compatibility) isn't in python-docx's prefix map, + # so qn() can't resolve it — use the namespace URI directly. + mc_fallback = ( + "{http://schemas.openxmlformats.org/markup-compatibility/2006}" + "Fallback" + ) + + def _para_runs(elem, out): + # Walk a paragraph's inline tree in document order, translating + # leaf elements to text. Descends through runs, hyperlinks, + # inline content controls (w:sdt), and textbox content so their + # text is captured — but skips ``mc:Fallback`` (the VML twin of + # a DrawingML textbox) so shape text is not emitted twice. + for child in elem: + tag = child.tag + if tag == w_t: + out.append(child.text or "") + elif tag == w_tab: + out.append("\t") + elif tag in (w_br, w_cr): + out.append("\n") + elif tag == mc_fallback: + continue + else: + _para_runs(child, out) def _paragraph_text(p_elem): - # Join every ``w:t`` descendant — captures runs nested in - # hyperlinks and inline content controls that Paragraph.text - # (direct runs only) would silently drop. - return "".join(node.text or "" for node in p_elem.iter(w_t)).strip() + out = [] + _para_runs(p_elem, out) + return "".join(out).strip() + + def _iter_children(elem, wanted): + # Yield direct ``wanted`` children, transparently descending + # through w:sdt wrappers (repeating-section / row / cell content + # controls keep their rows and cells inside w:sdtContent). + for child in elem: + if child.tag == wanted: + yield child + elif child.tag == w_sdt: + content = child.find(w_sdt_content) + if content is not None: + yield from _iter_children(content, wanted) def _emit(elem, parts): # Recursively walk a block element in document order, appending @@ -1702,9 +1753,9 @@ def _emit(elem, parts): if para_text: parts.append(para_text) elif elem.tag == w_tbl: - for row in elem.findall(w_tr): + for row in _iter_children(elem, w_tr): cells = [] - for cell in row.findall(w_tc): + for cell in _iter_children(row, w_tc): cell_parts = [] for cell_child in cell: _emit(cell_child, cell_parts) diff --git a/tests/unit/rag/test_docx_extraction.py b/tests/unit/rag/test_docx_extraction.py index 6dfce4ecd..e4f6ab376 100644 --- a/tests/unit/rag/test_docx_extraction.py +++ b/tests/unit/rag/test_docx_extraction.py @@ -240,6 +240,100 @@ def test_hyperlink_text_captured(self, rag, tmp_path): assert "the documentation" in text + def test_tabs_and_breaks_become_whitespace(self, rag, tmp_path): + """w:tab / w:br must inject whitespace so adjacent runs don't glue. + + A naive ``w:t``-only join turns a tab-separated label/value pair into + an unsearchable concatenation (``Column1Column2``). + """ + doc = Document() + para = doc.add_paragraph() + para._p.append( + parse_xml( + f"Column1" + f"Column2NextLine" + ) + ) + docx_path = tmp_path / "tabs.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Column1Column2" not in text + assert "Column1" in text and "Column2" in text and "NextLine" in text + + def test_intra_word_runs_not_split(self, rag, tmp_path): + """Runs split mid-word (formatting boundaries) must NOT gain spaces.""" + doc = Document() + para = doc.add_paragraph() + # "Hel" + "lo Wor" + "ld" — formatting splits, not word boundaries. + para._p.append(parse_xml(f"Hel")) + para._p.append( + parse_xml( + f'lo Wor' + ) + ) + para._p.append(parse_xml(f"ld")) + docx_path = tmp_path / "intraword.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "Hello World" in text + + def test_textbox_text_captured_once(self, rag, tmp_path): + """DrawingML textbox text is captured exactly once (mc:Fallback skipped). + + Word writes a shape as mc:AlternateContent with a DrawingML mc:Choice + and a VML mc:Fallback that carries the SAME text — a recursive w:t walk + would otherwise emit it twice and produce garbage duplicate tokens. + """ + doc = Document() + para = doc.add_paragraph("Body before. ") + # Minimal AlternateContent: Choice (DrawingML) + Fallback (VML) both + # carry the same text, exactly as Word writes a textbox/shape. The + # walker must descend into Choice and skip Fallback. + ns = ( + 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"' + ) + alt = parse_xml( + f"" + f'' + f"TEXTBOX_CONTENT" + f"" + f"TEXTBOX_CONTENT" + f"" + ) + para._p.append(alt) + docx_path = tmp_path / "textbox.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert text.count("TEXTBOX_CONTENT") == 1 + assert "Body before." in text + + def test_sdt_wrapped_table_row_captured(self, rag, tmp_path): + """A row wrapped in a repeating-section content control is not dropped.""" + doc = Document() + table = doc.add_table(rows=1, cols=1) + table.cell(0, 0).text = "PlainRow" + # Wrap a second row in a w:sdt directly under the table element. + sdt_row = parse_xml( + f"" + f"WRAPPED_ROW" + f"" + ) + table._tbl.append(sdt_row) + docx_path = tmp_path / "sdt_row.docx" + doc.save(str(docx_path)) + + text = rag._extract_text_from_docx(str(docx_path)) + + assert "PlainRow" in text + assert "WRAPPED_ROW" in text + # --------------------------------------------------------------------------- # Tests — Edge Cases / Errors From fa01b9d370643075965ea821dd6bd8200ed2462f Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 16:51:03 -0700 Subject: [PATCH 09/12] docs(rag): move .docx TODO out of docstring body (review nit) --- src/gaia/rag/sdk.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index 9b49da943..9fccfede5 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -1620,8 +1620,8 @@ def _extract_text_from_docx(self, docx_path: str) -> str: a ZIP container like .pptx). Known omissions: header/footer text (separate XML parts, usually - repeated boilerplate) and embedded images. - # TODO(#1072): VLM extraction for images embedded in .docx files. + repeated boilerplate) and embedded images (TODO #1072: VLM extraction + for images embedded in .docx files). Returns: The extracted text as a single string. From fa94ee83bb214f8770c2046bc9833670c3302cd1 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 29 Jun 2026 13:55:20 -0700 Subject: [PATCH 10/12] style(tests): drop stray blank line from merge resolution Black/Flake8 E303 in test_agents_split.py after the main merge. --- tests/unit/test_agents_split.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_agents_split.py b/tests/unit/test_agents_split.py index 9dc8daa03..c194ed1c1 100644 --- a/tests/unit/test_agents_split.py +++ b/tests/unit/test_agents_split.py @@ -166,7 +166,6 @@ def test_get_mcp_status_report_does_not_raise(tmp_path): from gaia_agent_docqa.agent import DocumentQAAgent from gaia_agent_fileio.agent import FileIOAgent - agents = [ BrowserAgent(), AnalystAgent(AnalystAgentConfig(scratchpad_db_path=str(tmp_path / "s.db"))), From 25cbb4daea55d46ca0c8dd4c4e1ecd24534785c2 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 29 Jun 2026 14:13:12 -0700 Subject: [PATCH 11/12] docs(chat): show .docx/.xlsx in RAG --index examples The intro note listed Word/Excel as supported but the CLI examples still showed only PDF/PPTX, so a user couldn't tell .docx was indexable. --- docs/guides/chat.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/guides/chat.mdx b/docs/guides/chat.mdx index f4d71a4aa..7cfdc105a 100644 --- a/docs/guides/chat.mdx +++ b/docs/guides/chat.mdx @@ -110,16 +110,18 @@ RAG (Retrieval-Augmented Generation) enables chatting with documents — includi ```bash - # Chat with a PDF or PowerPoint document + # Chat with a PDF, Word, PowerPoint, or Excel document gaia chat --index manual.pdf + gaia chat --index handbook.docx gaia chat --index slides.pptx + gaia chat --index budget.xlsx ``` ```bash - # Chat with multiple documents (PDF and PPTX supported) - gaia chat --index doc1.pdf doc2.pdf slides.pptx + # Chat with multiple documents (PDF, DOCX, PPTX, XLSX supported) + gaia chat --index doc1.pdf report.docx slides.pptx ``` @@ -132,7 +134,7 @@ RAG (Retrieval-Augmented Generation) enables chatting with documents — includi ```bash - # Auto-index every PDF/PPTX in a folder, and any new ones dropped in later + # Auto-index every supported document in a folder, and any new ones dropped in later gaia chat --watch ./docs ``` From 8cd920982bb9208bddce4a63999224c2613243ed Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 29 Jun 2026 18:03:25 -0700 Subject: [PATCH 12/12] fix(setup): drop duplicate agent-docqa/agent-routing extras keys Merge re-inserted both keys above the pre-existing entries, producing duplicate dict keys (Python 3.12 SyntaxWarning) and double list entries in the agents meta-extra. --- setup.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.py b/setup.py index 0e92ed5c7..a9c9e0b21 100644 --- a/setup.py +++ b/setup.py @@ -271,8 +271,6 @@ "agent-docqa": ["gaia-agent-docqa"], "agent-routing": ["gaia-agent-routing"], "agent-email": ["gaia-agent-email"], - "agent-docqa": ["gaia-agent-docqa"], - "agent-routing": ["gaia-agent-routing"], "agent-chat": ["gaia-agent-chat"], "agents": [ "gaia-agent-summarize", @@ -289,8 +287,6 @@ "gaia-agent-docqa", "gaia-agent-routing", "gaia-agent-email", - "gaia-agent-docqa", - "gaia-agent-routing", "gaia-agent-chat", ], },