Skip to content

Commit f6e70ab

Browse files
author
Alexey Tyurin
committed
fix(agents): clean up skeleton dep stubs + address review feedback (#1449)
1 parent 2c3ed7c commit f6e70ab

7 files changed

Lines changed: 100 additions & 54 deletions

File tree

docs/plans/tool-loader.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,11 @@ otherwise `self.tool_loader is None` and the agent stays on the legacy path.
275275
**Bundles (cohesion groups, pulled in whole on a member match):** `rag_query`,
276276
`rag_index`, `file_search`, `file_browse`, `file_edit`, `data`, `shell`,
277277
`clipboard`, `desktop`, `vision`, `memory`, `loop_control`. CORE ∪ all bundle
278-
members must equal the 37-tool `doc` registry exactly — enforced at runtime by
279-
`ToolLoader.validate_registry` and in CI by `test_chat_tool_bundles.py`, so a new
280-
doc tool forces a conscious bundling decision.
278+
members must equal the 37-tool `doc` registry exactly. The drift guard is the CI
279+
test `test_chat_tool_bundles.py` (it compares both sets, so a new doc tool forces
280+
a conscious bundling decision); `ToolLoader.validate_registry`, called once on
281+
first `select`, additionally fails loudly at runtime if a CORE/bundle name is
282+
missing from the registry.
281283

282284
**Selection each turn** = CORE ∪ semantically-matched tools (+ their bundles),
283285
accumulated into a session-scoped *loaded set* that only grows, then sorted. The

src/gaia/agents/base/tool_loader.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,14 @@ def session_disabled(self) -> bool:
167167
def validate_registry(self, registry: Dict[str, dict]) -> None:
168168
"""Raise if CORE or any bundle names a tool absent from *registry*.
169169
170-
Fails loudly on drift so a new doc-profile tool forces a conscious
171-
bundling decision rather than silently slipping through unselected.
170+
Checks the config→registry direction only (no dead/misspelled CORE or
171+
bundle names). The host agent calls this once on first activation
172+
(ChatAgent does, since its doc CORE∪bundles must cover the registry) so
173+
genuine drift fails loudly at runtime. ``select`` itself does *not* call
174+
it — the loader tolerates bundle members absent from the registry by
175+
design. The reverse direction (a *registry* tool not covered by
176+
CORE/bundles, which would silently never be surfaced) is the CI bundle
177+
test's job — it compares both sets exactly.
172178
"""
173179
names = set(registry)
174180
missing_core = sorted(self._core - names)

src/gaia/agents/chat/agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ def __init__(self, config: Optional[ChatAgentConfig] = None):
343343
# loader never imports MemoryMixin; they resolve lazily on first select(),
344344
# by which point init_memory() has probed the embedder.
345345
self._dynamic_tools_native_warned = False
346+
self._dynamic_tools_validated = False
346347
self.tool_loader = self._maybe_build_tool_loader()
347348

348349
# Initialize memory subsystem (before super().__init__ which calls _register_tools)
@@ -521,6 +522,11 @@ def _select_tools_for_turn(self, user_input: str) -> Optional[List[str]]:
521522
if not self._dynamic_tools_active():
522523
return None
523524
self._maybe_warn_native_tool_gap()
525+
if not self._dynamic_tools_validated:
526+
# Fail loudly on first activation if a CORE/bundle name doesn't exist
527+
# in the live registry (drift). The reverse direction is the CI test.
528+
self.tool_loader.validate_registry(self._tools_registry)
529+
self._dynamic_tools_validated = True
524530
query = self._build_tool_selection_query(user_input)
525531
return self.tool_loader.select(query, self._tools_registry)
526532

src/gaia/agents/chat/tool_bundles.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
pulled in whole when any member is semantically matched.
88
99
``DOC_CORE_TOOLS`` ∪ all ``DOC_BUNDLES`` members must equal the doc-profile
10-
registry **exactly** — enforced at runtime by ``ToolLoader.validate_registry``
11-
(fails loudly on drift) and in CI by ``tests/unit/test_chat_tool_bundles.py``. A
12-
new doc-profile tool therefore forces a conscious bundling decision instead of
13-
silently shipping unselected.
10+
registry **exactly**. The drift guard is the CI test
11+
``tests/unit/test_chat_tool_bundles.py`` — it compares both sets and fails the
12+
build if a registry tool is uncovered *or* a configured name is absent, so a new
13+
doc-profile tool forces a conscious bundling decision instead of silently
14+
shipping unselected. At runtime, ``ToolLoader.validate_registry`` (called once on
15+
first ``select``) additionally fails loudly if any CORE/bundle name is missing
16+
from the registry (the config→registry direction only).
1417
"""
1518

1619
from __future__ import annotations

src/gaia/eval/tool_cost.py

Lines changed: 63 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,24 @@
8888
)
8989

9090

91-
def _ensure_optional_deps_stubbed() -> None:
92-
"""Stub missing heavy optional deps so the chat agent can import."""
91+
def _ensure_optional_deps_stubbed() -> List[str]:
92+
"""Stub missing heavy optional deps so the chat agent can import.
93+
94+
Returns the names this call newly stubbed, so the caller can remove them
95+
again — a leaked ``MagicMock`` makes ``import faiss`` *succeed* for later
96+
tests that probe for the real dep, turning their graceful-skip into a hard
97+
failure. Modules already present (real or stubbed by someone else) are left
98+
untouched and not returned.
99+
"""
100+
stubbed: List[str] = []
93101
for mod in _OPTIONAL_DEPS:
94102
if mod in sys.modules:
95103
continue
96104
if importlib.util.find_spec(mod) is not None:
97105
continue
98106
sys.modules[mod] = MagicMock()
107+
stubbed.append(mod)
108+
return stubbed
99109

100110

101111
def get_tokenizer() -> Optional[Any]:
@@ -156,50 +166,60 @@ def build_doc_agent_skeleton(
156166
157167
The freshly registered tools are snapshotted into the instance via
158168
``_instance_tools``; the global registry is restored on the way out.
159-
"""
160-
_ensure_optional_deps_stubbed()
161-
162-
from gaia.agents.chat.agent import ChatAgent, ChatAgentConfig
163169
164-
cfg = ChatAgentConfig(
165-
rag_documents=[],
166-
streaming=False,
167-
silent_mode=True,
168-
prompt_profile=profile,
169-
)
170+
Cleans up after itself: any ``MagicMock`` dep stubs this call adds are
171+
removed from ``sys.modules`` on exit. Without that, a leaked ``faiss``/
172+
``pypdf`` mock makes ``import faiss`` *succeed* for later tests that probe
173+
for the real dep — turning their graceful-skip into a hard failure. (Only
174+
the stubs are removed; gaia modules stay cached, since the consumers that
175+
matter import these deps lazily, so dropping the stub is enough.)
176+
"""
177+
stubbed = _ensure_optional_deps_stubbed()
178+
try:
179+
from gaia.agents.chat.agent import ChatAgent, ChatAgentConfig
170180

171-
with _isolated_registry() as tools_mod:
172-
stack = contextlib.ExitStack()
173-
stack.enter_context(
174-
patch("gaia.agents.base.agent.Agent.__init__", return_value=None)
181+
cfg = ChatAgentConfig(
182+
rag_documents=[],
183+
streaming=False,
184+
silent_mode=True,
185+
prompt_profile=profile,
175186
)
176-
if deterministic:
177-
# No npx -> search_documentation skipped; scrub PERPLEXITY_API_KEY
178-
# -> search_web skipped. clear=True + filtered copy removes the key
179-
# for the duration and restores the full environment afterwards.
180-
stack.enter_context(patch("shutil.which", return_value=None))
181-
scrubbed = {
182-
k: v for k, v in os.environ.items() if k != "PERPLEXITY_API_KEY"
183-
}
184-
stack.enter_context(patch.dict(os.environ, scrubbed, clear=True))
185-
186-
with stack:
187-
agent = ChatAgent.__new__(ChatAgent)
188-
agent.config = cfg
189-
agent._instance_tools = None
190-
agent.model_id = "Gemma-4-E4B-it-GGUF"
191-
agent._memory_store = MagicMock() # non-None -> memory tools register
192-
agent.rag = MagicMock()
193-
agent.console = MagicMock()
194-
# Satisfy ChatAgent.__del__ so GC of the skeleton stays quiet.
195-
agent.observers = []
196-
agent._web_client = None
197-
agent._fs_index = None
198-
agent._scratchpad = None
199-
agent._register_tools()
200-
agent._instance_tools = dict(tools_mod._TOOL_REGISTRY)
201-
202-
return agent
187+
188+
with _isolated_registry() as tools_mod:
189+
stack = contextlib.ExitStack()
190+
stack.enter_context(
191+
patch("gaia.agents.base.agent.Agent.__init__", return_value=None)
192+
)
193+
if deterministic:
194+
# No npx -> search_documentation skipped; scrub PERPLEXITY_API_KEY
195+
# -> search_web skipped. clear=True + filtered copy removes the key
196+
# for the duration and restores the full environment afterwards.
197+
stack.enter_context(patch("shutil.which", return_value=None))
198+
scrubbed = {
199+
k: v for k, v in os.environ.items() if k != "PERPLEXITY_API_KEY"
200+
}
201+
stack.enter_context(patch.dict(os.environ, scrubbed, clear=True))
202+
203+
with stack:
204+
agent = ChatAgent.__new__(ChatAgent)
205+
agent.config = cfg
206+
agent._instance_tools = None
207+
agent.model_id = "Gemma-4-E4B-it-GGUF"
208+
agent._memory_store = MagicMock() # non-None -> memory tools register
209+
agent.rag = MagicMock()
210+
agent.console = MagicMock()
211+
# Satisfy ChatAgent.__del__ so GC of the skeleton stays quiet.
212+
agent.observers = []
213+
agent._web_client = None
214+
agent._fs_index = None
215+
agent._scratchpad = None
216+
agent._register_tools()
217+
agent._instance_tools = dict(tools_mod._TOOL_REGISTRY)
218+
219+
return agent
220+
finally:
221+
for mod in stubbed:
222+
sys.modules.pop(mod, None)
203223

204224

205225
def _render_paths(

src/gaia/eval/tool_recall.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ def parse_loaded_sets_from_log(text: str) -> List[List[List[str]]]:
132132
133133
A new scenario begins at each ``"turn": 1`` selection line (the loader resets
134134
its turn counter per conversation).
135+
136+
Assumption: every scenario emits a ``turn == 1`` line. A turn-1 *embedder
137+
failure* session-disables the loader before ``_log_selection`` runs, so that
138+
scenario emits no ``TOOL_LOADER`` line and its boundary is missed — quietly
139+
shifting the per-scenario alignment. This is an eval-tooling edge case (the
140+
embedder is up for a real recall run); if it happens, the per-scenario /
141+
per-turn count mismatch surfaces as an alignment warning in
142+
:func:`compute_recall` rather than passing silently.
135143
"""
136144
scenarios: List[List[List[str]]] = []
137145
current: List[List[str]] = []
@@ -167,8 +175,8 @@ def _model_is_native(scorecard: Dict) -> bool:
167175
from gaia.llm.lemonade_client import is_tool_calling_model
168176

169177
return is_tool_calling_model(model)
170-
except Exception:
171-
# If we can't classify, treat as native so misses don't hard-fail.
178+
except ImportError:
179+
# Can't import the classifier — treat as native so misses don't hard-fail.
172180
return True
173181

174182

tests/unit/test_chat_dynamic_tools.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def _bare_agent(**attrs) -> ChatAgent:
4545
a.tool_loader = None
4646
a._memory_store = object()
4747
a._dynamic_tools_native_warned = False
48+
a._dynamic_tools_validated = False
4849
a.model_id = None
4950
for k, v in attrs.items():
5051
setattr(a, k, v)

0 commit comments

Comments
 (0)