From 353a90716ff7b3ea5c712b2be4b82e811bc421ad Mon Sep 17 00:00:00 2001 From: Samurai007AK Date: Thu, 3 Sep 2026 22:46:22 +0530 Subject: [PATCH 1/3] fix(apodex): preflight search credentials from the profile's tools The SERPER_API_KEY / JINA_API_KEY check was keyed on the research mode, which the terminal cannot select, so it never fired for react or agent_team, both of which bind web_search and web_fetch. A blank key first surfaced as an error string inside a tool result. Gate on the tools the profile declares, and drop the Serper issue to a warning so a coding session with no search key still starts. Closes #34 --- apodex/config.py | 47 +++++++++++++--------- apodex/profiles/__init__.py | 5 +++ apodex/tests/test_config_preflight.py | 57 ++++++++++++++++++++------- 3 files changed, 76 insertions(+), 33 deletions(-) diff --git a/apodex/config.py b/apodex/config.py index 0687d03..7ba9bb1 100644 --- a/apodex/config.py +++ b/apodex/config.py @@ -147,25 +147,34 @@ def inspect_runtime_config( env_var=profile.base_url_env, )) - if active_mode == "research": - if not _configured(env.get("SERPER_API_KEY")): - issues.append(RuntimeConfigIssue( - code="missing_serper_api_key", - message=( - "SERPER_API_KEY is required in research mode because " - "web_search cannot return results without it." - ), - env_var="SERPER_API_KEY", - )) - if not _configured(env.get("JINA_API_KEY")): - issues.append(RuntimeConfigIssue( - code="missing_jina_api_key", - message=( - "JINA_API_KEY is missing; web_fetch will use its direct-fetch fallback." - ), - env_var="JINA_API_KEY", - blocking=False, - )) + # Gate the search credentials on the tools the profile actually binds, not + # on the mode name. Keying this on ``research`` meant it never fired: the + # terminal only exposes ``react`` and ``agent_team``, and both bind + # web_search and web_fetch, so a missing key first surfaced as an error + # string inside a tool result. + tool_names = frozenset(getattr(profile, "tool_names", ()) or ()) + if "web_search" in tool_names and not _configured(env.get("SERPER_API_KEY")): + issues.append(RuntimeConfigIssue( + code="missing_serper_api_key", + message=( + "SERPER_API_KEY is not set; web_search will return an error " + "instead of results for every query this session makes." + ), + env_var="SERPER_API_KEY", + # A warning, not a blocker: web_search is one of seven tools these + # profiles bind, so a local coding session has no use for the key + # and must not be refused a startup over it. + blocking=False, + )) + if "web_fetch" in tool_names and not _configured(env.get("JINA_API_KEY")): + issues.append(RuntimeConfigIssue( + code="missing_jina_api_key", + message=( + "JINA_API_KEY is missing; web_fetch will use its direct-fetch fallback." + ), + env_var="JINA_API_KEY", + blocking=False, + )) return RuntimeConfigStatus( mode=active_mode, diff --git a/apodex/profiles/__init__.py b/apodex/profiles/__init__.py index 7b54e35..f156935 100644 --- a/apodex/profiles/__init__.py +++ b/apodex/profiles/__init__.py @@ -59,6 +59,10 @@ class AgentProfile: # the lightweight terminal ReAct loop. workflow: str | None = None workflow_profile: str | None = None + # The tool names the profile declares. ``tools`` is a factory that imports + # and builds the tool registry, which a local config preflight has no reason + # to pay for, so the declared names are kept alongside it. + tool_names: tuple[str, ...] = () def runtime_config( self, cfg: ModelConfig, *, mode: str | None = None, @@ -238,6 +242,7 @@ def _build(name: str) -> AgentProfile: models=models, system_prompt=system_prompt, tools=_tool_factory([str(t) for t in (raw.get("tools") or [])]), + tool_names=tuple(str(t) for t in (raw.get("tools") or [])), skills=[str(s) for s in (raw.get("skills") or [])], extra_observers=_robustness_observers, max_turns=int(max_turns) if max_turns is not None else None, diff --git a/apodex/tests/test_config_preflight.py b/apodex/tests/test_config_preflight.py index 13546b8..333876f 100644 --- a/apodex/tests/test_config_preflight.py +++ b/apodex/tests/test_config_preflight.py @@ -25,6 +25,7 @@ def _profile(**overrides): "api_key_env": "OPENAI_API_KEY", "base_url_env": "OPENAI_BASE_URL", "model_env": "OPENAI_MODEL", + "tool_names": (), } values.update(overrides) return SimpleNamespace(**values) @@ -84,27 +85,55 @@ def test_local_empty_placeholder_is_allowed(): assert status.endpoint_host == "localhost" -def test_research_serper_blocks_and_jina_warns_but_coding_ignores_both(): +_WEB_TOOLS = ("web_search", "web_fetch", "bash", "read_file") + + +def test_search_credentials_are_checked_when_the_profile_binds_the_web_tools(): cfg = ModelConfig(model="gpt-test", api_key="secret", base_url="https://api.test/v1") - research = inspect_runtime_config(cfg, profile=_profile(name="research"), environ={}) - assert [issue.code for issue in research.errors] == ["missing_serper_api_key"] - assert [issue.code for issue in research.warnings] == ["missing_jina_api_key"] - rendered = format_runtime_config_status(research) - assert "error: SERPER_API_KEY" in rendered + web = inspect_runtime_config( + cfg, profile=_profile(tool_names=_WEB_TOOLS), environ={}, + ) + # Both warn: a coding session that never searches must still start. + assert web.ok + assert [issue.code for issue in web.warnings] == [ + "missing_serper_api_key", "missing_jina_api_key", + ] + rendered = format_runtime_config_status(web) + assert "warning: SERPER_API_KEY" in rendered assert "warning: JINA_API_KEY" in rendered - research_with_search = inspect_runtime_config( + with_search = inspect_runtime_config( cfg, - profile=_profile(name="research"), + profile=_profile(tool_names=_WEB_TOOLS), environ={"SERPER_API_KEY": "search-secret"}, ) - assert research_with_search.ok - assert [issue.code for issue in research_with_search.warnings] == [ - "missing_jina_api_key", - ] + assert with_search.ok + assert [issue.code for issue in with_search.warnings] == ["missing_jina_api_key"] - coding = inspect_runtime_config(cfg, profile=_profile(), environ={}) - assert coding.ok and not coding.warnings + no_web_tools = inspect_runtime_config( + cfg, profile=_profile(tool_names=("bash", "read_file")), environ={}, + ) + assert no_web_tools.ok and not no_web_tools.warnings + + +def test_every_selectable_terminal_mode_preflights_its_search_credentials(): + """The check used to key on ``research``, a mode the CLI cannot select. + + Both shipped modes bind web_search, so a blank SERPER_API_KEY reached the + model as an error string inside a tool result instead of failing preflight. + """ + from apodex.profiles import get_profile, terminal_mode_names + + cfg = ModelConfig(model="gpt-test", api_key="secret", base_url="https://api.test/v1") + modes = terminal_mode_names() + assert modes, "the terminal must expose at least one mode" + for mode in modes: + profile = get_profile(mode) + assert "web_search" in profile.tool_names, mode + status = inspect_runtime_config(cfg, profile=profile, mode=mode, environ={}) + assert "missing_serper_api_key" in [i.code for i in status.warnings], mode + # Warned about, never refused a startup. + assert status.ok, mode def test_cli_fails_before_session_construction_with_actionable_guidance( From ee5128e91750744b97cc6b6a92c72d9f2d9855d0 Mon Sep 17 00:00:00 2001 From: Samurai007AK Date: Sat, 5 Sep 2026 13:19:25 +0530 Subject: [PATCH 2/3] =?UTF-8?q?=EF=BB=BFfix(apodex):=20preflight=20the=20w?= =?UTF-8?q?orkflow's=20own=20tools=20and=20surface=20warnings=20in=20the?= =?UTF-8?q?=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the tool-based preflight. Warnings were printed to stderr moments before Textual takes the alternate screen, so under the default TUI they were gone before the session started. cli.py now prints them only in line mode and hands them to FrontierAgentApp, which renders them in the transcript after mount. AgentProfile.tool_names read the top-level `tools:` of the apodex YAML, but react and agent_team dispatch to a native workflow and never bind that list. It is now a property resolving agent_tools / main_agent_tools / sub_agent_tools from the workflow profile, through that workflow's own loader so aliases and ${VAR} resolve as they do at dispatch. The duplicated `tools:` lists are deleted rather than worked around: nothing read them, and a dead copy is what drifts. agent_team's coordinator binds no web_search while its sub-agents do, which the old list could not express either way. The loaders log their own config diagnostics, and dispatch loads the same profile again and logs them where the renderer routes them, so the preflight read is muted to avoid printing each one twice. --- apodex/cli.py | 10 ++++- apodex/profiles/__init__.py | 71 +++++++++++++++++++++++++++++--- apodex/profiles/agent_team.yaml | 8 ++-- apodex/profiles/react.yaml | 5 ++- apodex/tests/test_profiles.py | 72 +++++++++++++++++++++++++++++++++ apodex/tests/test_tui.py | 11 +++++ apodex/tui/app.py | 16 ++++++-- 7 files changed, 179 insertions(+), 14 deletions(-) diff --git a/apodex/cli.py b/apodex/cli.py index 624a729..4ed29c7 100644 --- a/apodex/cli.py +++ b/apodex/cli.py @@ -514,8 +514,13 @@ async def _amain(argv: list[str] | None = None) -> int: if not runtime_config.ok: print(format_preflight_errors(runtime_config), file=sys.stderr) return 2 - for warning in runtime_config.warnings: - print(f"warning: {warning.message}", file=sys.stderr) + # stderr is written moments before Textual takes the alternate screen, so a + # warning printed here is gone by the time the TUI is up. The TUI path + # carries them into the transcript instead; line mode prints as before. + startup_warnings = [warning.message for warning in runtime_config.warnings] + if not use_tui: + for message in startup_warnings: + print(f"warning: {message}", file=sys.stderr) session = TerminalSession( cfg=cfg, @@ -545,6 +550,7 @@ async def _amain(argv: list[str] | None = None) -> int: app = FrontierAgentApp( session, resumed=resumed_state is not None, initial_task=args.task, theme=theme, + startup_warnings=startup_warnings, ) _route_engine_logs(app.sink, session.session_id) await app.run_async() diff --git a/apodex/profiles/__init__.py b/apodex/profiles/__init__.py index f156935..961c5a9 100644 --- a/apodex/profiles/__init__.py +++ b/apodex/profiles/__init__.py @@ -18,9 +18,12 @@ from __future__ import annotations +import importlib +import logging import os from collections.abc import Callable from dataclasses import dataclass +from functools import cache from pathlib import Path from typing import Any @@ -31,6 +34,50 @@ _USER_DIR = Path(os.path.expanduser("~/.apodex/profiles")) _TERMINAL_WORKFLOW_MODES = ("react", "agent_team") +# ``workflow:`` → the module and loader that resolve its ``workflow_profile:``. +# Add a row when a new workflow ships a profile loader. +_WORKFLOW_PROFILE_LOADERS = { + "stateful-react-agent": ( + "workflows.stateful_react_agent.profile", "load_react_profile", + ), + "agent_team": ("workflows.agent_team.profile", "load_swarm_profile"), +} +# Where a workflow profile lists the tools it binds: one allowlist for the ReAct +# agent, one per role for the team. +_WORKFLOW_TOOL_KEYS = ("agent_tools", "main_agent_tools", "sub_agent_tools") + + +@cache +def _workflow_tool_names(workflow: str, workflow_profile: str) -> tuple[str, ...]: + """The tools ``workflow`` binds under ``workflow_profile``. + + Resolved through the workflow's own loader rather than by reading the YAML + path, so profile aliases, ``${VAR}`` expansion and shipped overrides give + the same answer here as they do at dispatch — one source of truth instead + of a second list to keep in step. + """ + entry = _WORKFLOW_PROFILE_LOADERS.get(workflow) + if entry is None or not workflow_profile: + return () + module_path, loader_name = entry + # The loaders log their own config diagnostics (provider label mismatches, + # empty keys). Dispatch loads the same profile again and logs them there, + # where ``_route_engine_logs`` puts them in front of the user; emitting them + # here as well only doubles them onto a stderr the TUI is about to cover. + previous = logging.root.manager.disable + try: + loader = getattr(importlib.import_module(module_path), loader_name) + logging.disable(logging.WARNING) + agent = (loader(workflow_profile) or {}).get("agent") or {} + except Exception: + # A preflight reports on the run; it must never be the thing that stops + # one. An unreadable workflow profile simply checks no tool credentials. + return () + finally: + logging.disable(previous) + names = [str(t) for key in _WORKFLOW_TOOL_KEYS for t in (agent.get(key) or [])] + return tuple(dict.fromkeys(names)) + @dataclass(frozen=True) class AgentProfile: @@ -59,10 +106,24 @@ class AgentProfile: # the lightweight terminal ReAct loop. workflow: str | None = None workflow_profile: str | None = None - # The tool names the profile declares. ``tools`` is a factory that imports - # and builds the tool registry, which a local config preflight has no reason - # to pay for, so the declared names are kept alongside it. - tool_names: tuple[str, ...] = () + # The tool names the YAML declares. ``tools`` is a factory that imports and + # builds the tool registry, which a local config preflight has no reason to + # pay for, so the declared names are kept alongside it. Read + # ``tool_names`` — not this — for what the profile actually runs with. + declared_tools: tuple[str, ...] = () + + @property + def tool_names(self) -> tuple[str, ...]: + """The tool names this profile actually runs with. + + A workflow-backed profile (``react``, ``agent_team``) never binds + ``declared_tools``: dispatch hands the run to the native workflow, whose + own profile carries the allowlists. Reading the top-level list would + report on tools that are not bound and miss tools that are. + """ + if self.workflow: + return _workflow_tool_names(self.workflow, self.workflow_profile or "") + return self.declared_tools def runtime_config( self, cfg: ModelConfig, *, mode: str | None = None, @@ -242,7 +303,7 @@ def _build(name: str) -> AgentProfile: models=models, system_prompt=system_prompt, tools=_tool_factory([str(t) for t in (raw.get("tools") or [])]), - tool_names=tuple(str(t) for t in (raw.get("tools") or [])), + declared_tools=tuple(str(t) for t in (raw.get("tools") or [])), skills=[str(s) for s in (raw.get("skills") or [])], extra_observers=_robustness_observers, max_turns=int(max_turns) if max_turns is not None else None, diff --git a/apodex/profiles/agent_team.yaml b/apodex/profiles/agent_team.yaml index 15e4da0..0e1707e 100644 --- a/apodex/profiles/agent_team.yaml +++ b/apodex/profiles/agent_team.yaml @@ -14,7 +14,9 @@ llm: context_window: ${OPENAI_CONTEXT_WINDOW:-262144} # Turn budgets live in the selected workflow profile. ``agent.max_turns`` here # would configure apodex's generic loop, which workflow dispatch does not run. -# The coordinator uses the orchestration tools; its sub-agents receive the -# web/file tools declared in workflows/agent_team/profiles/tui.yaml. -tools: [web_search, web_fetch, bash, grep_search, glob_search, read_file, todo_write] +# No ``tools:`` here on purpose. Workflow dispatch never calls +# ``profile.tools()``; the coordinator's orchestration tools and its +# sub-agents' web/file tools are the allowlists in +# workflows/agent_team/profiles/tui.yaml, and a second copy here could only +# drift out of step with them. skills: [] diff --git a/apodex/profiles/react.yaml b/apodex/profiles/react.yaml index f7e2224..a393072 100644 --- a/apodex/profiles/react.yaml +++ b/apodex/profiles/react.yaml @@ -15,5 +15,8 @@ llm: context_window: ${OPENAI_CONTEXT_WINDOW:-262144} # Turn budgets live in the selected workflow profile. ``agent.max_turns`` here # would configure apodex's generic loop, which workflow dispatch does not run. -tools: [web_search, web_fetch, bash, grep_search, glob_search, read_file, todo_write] +# No ``tools:`` here on purpose. Workflow dispatch never calls +# ``profile.tools()``; the tools this mode binds are the allowlists in +# workflows/stateful_react_agent/profiles/tui.yaml, and a second copy here +# could only drift out of step with them. skills: [] diff --git a/apodex/tests/test_profiles.py b/apodex/tests/test_profiles.py index 112b7fe..b52be51 100644 --- a/apodex/tests/test_profiles.py +++ b/apodex/tests/test_profiles.py @@ -6,6 +6,9 @@ from __future__ import annotations +import logging +import pathlib + import pytest import apodex.profiles as P @@ -16,9 +19,11 @@ def _fresh_caches(): """Each test resolves profiles/providers from the current env.""" P._CACHE.clear() + P._workflow_tool_names.cache_clear() providers._reset_cache() yield P._CACHE.clear() + P._workflow_tool_names.cache_clear() providers._reset_cache() @@ -100,6 +105,73 @@ def test_native_workflow_modes_are_explicit_and_use_shipped_profiles(monkeypatch ) +def test_workflow_modes_expose_the_tools_their_workflow_profile_binds(monkeypatch): + """``tool_names`` must be the list that runs, not a second copy of it. + + ``react``/``agent_team`` dispatch to a native workflow and never bind a + top-level ``tools:``, so reading one would both miss the coordinator-only + tools and report on tools the workflow does not have. + """ + import yaml + + monkeypatch.setenv("OPENAI_API_KEY", "test") + monkeypatch.setenv("OPENAI_BASE_URL", "https://example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "test-model") + + repo = pathlib.Path(P.__file__).resolve().parents[2] + for mode in ("react", "agent_team"): + profile = P.get_profile(mode) + assert profile.declared_tools == () # nothing to drift + workflow_yaml = ( + repo / "workflows" / profile.workflow.replace("-", "_") + / "profiles" / f"{profile.workflow_profile}.yaml" + ) + agent = yaml.safe_load(workflow_yaml.read_text(encoding="utf-8"))["agent"] + expected = { + str(t) + for key in ("agent_tools", "main_agent_tools", "sub_agent_tools") + for t in (agent.get(key) or []) + } + assert set(profile.tool_names) == expected, mode + assert "web_search" in profile.tool_names, mode + + # Coordinator-only, and present in no apodex profile YAML: it can only have + # come from the workflow profile. + assert "create_subagent" in P.get_profile("agent_team").tool_names + + +def test_reading_workflow_tool_names_stays_quiet(monkeypatch, caplog): + """The loader's own config warnings belong to the run, not the preflight. + + ``load_swarm_profile`` warns about provider label mismatches and empty + keys. Dispatch loads the same profile again and logs them where the + renderer routes them, so repeating them here would print each one twice, + the second time onto a stderr the TUI is about to cover. + """ + monkeypatch.setenv("OPENAI_PROVIDER", "local") + monkeypatch.setenv("OPENAI_API_KEY", "EMPTY") + monkeypatch.setenv("OPENAI_BASE_URL", "http://model:30000/v1") + monkeypatch.setenv("OPENAI_MODEL", "local-model") + + with caplog.at_level(logging.WARNING): + assert "web_search" in P._workflow_tool_names("agent_team", "tui") + assert caplog.records == [] + + # Restored, not left off: the same load logs normally at dispatch. + P._workflow_tool_names.cache_clear() + with caplog.at_level(logging.WARNING): + from workflows.agent_team.profile import load_swarm_profile + load_swarm_profile("tui") + assert caplog.records + + +def test_workflow_tool_names_never_break_startup(monkeypatch): + """An unreadable workflow profile checks no credentials; it does not raise.""" + P._workflow_tool_names.cache_clear() + assert P._workflow_tool_names("no-such-workflow", "tui") == () + assert P._workflow_tool_names("agent_team", "no-such-profile") == () + + def test_native_workflow_modes_accept_local_openai_compatible_provider(monkeypatch): """The GPU Compose path needs no real API key for its local SGLang server.""" monkeypatch.setenv("OPENAI_PROVIDER", "local") diff --git a/apodex/tests/test_tui.py b/apodex/tests/test_tui.py index 2c0af5c..07b9340 100644 --- a/apodex/tests/test_tui.py +++ b/apodex/tests/test_tui.py @@ -177,6 +177,17 @@ async def test_app_boots_streams_and_routes_task() -> None: await _wait_until(lambda: app.busy is False) +async def test_preflight_warnings_render_in_the_transcript_after_mount() -> None: + """stderr is gone once Textual owns the screen, so the TUI shows them.""" + app = FrontierAgentApp( + _FakeSession(), + startup_warnings=["SERPER_API_KEY is not set; web_search will error."], + ) + async with app.run_test() as pilot: + await pilot.pause() + assert app.transcript.apply_filter("search", "SERPER_API_KEY") == 1 + + async def test_attachment_commands_update_bar_and_remove_copy( monkeypatch, tmp_path, ) -> None: diff --git a/apodex/tui/app.py b/apodex/tui/app.py index 3f12da6..97d3af2 100644 --- a/apodex/tui/app.py +++ b/apodex/tui/app.py @@ -18,7 +18,7 @@ import os import shlex import time -from collections.abc import Callable +from collections.abc import Callable, Sequence from os.path import commonprefix from pathlib import Path from typing import Any, ClassVar @@ -49,7 +49,12 @@ ) from apodex.tui.sink import TuiApprover, TuiSink from apodex.tui.state import TuiPresentationState -from apodex.tui.themes import THEME_PICKER_NAMES, TUI_THEME_NAMES, register_themes +from apodex.tui.themes import ( + GLYPHS, + THEME_PICKER_NAMES, + TUI_THEME_NAMES, + register_themes, +) from apodex.tui.widgets import ( ActivityPane, ActivityRecord, @@ -440,7 +445,7 @@ class FrontierAgentApp(App): def __init__( self, session: Any, *, resumed: bool = False, initial_task: str | None = None, - theme: str = "catppuccin", + theme: str = "catppuccin", startup_warnings: Sequence[str] = (), ) -> None: super().__init__() # Before any input is read: Textual's escape-sequence collector gives up @@ -456,6 +461,9 @@ def __init__( self._tools = 0 self._resumed = resumed self._initial_task = initial_task + # Preflight findings (missing SERPER_API_KEY …). stderr is invisible + # once Textual owns the screen, so they are rendered after mount. + self._startup_warnings = tuple(startup_warnings) register_themes(self) self._ui_theme = theme if theme in TUI_THEME_NAMES else "catppuccin" self._input_history: list[str] = [] @@ -613,6 +621,8 @@ async def on_mount(self) -> None: f"resumed session {self.session.session_id} " f"({len(self.session.history)} prior messages)" ) + for message in self._startup_warnings: + self.sink.note(f"{GLYPHS['danger']} warning: {message}") if self._initial_task: self._remember_input(self._initial_task) self.sink.echo_user(self._initial_task) From 70b8659c64ac9a1bec96f2a3518fd3579120fdb5 Mon Sep 17 00:00:00 2001 From: Samurai007AK Date: Tue, 8 Sep 2026 14:49:57 +0530 Subject: [PATCH 3/3] fix(apodex): account for closed-book filtering in search credential preflight --- apodex/config.py | 75 ++++++++++++++++++++- apodex/profiles/__init__.py | 21 +++++- apodex/tests/test_config_preflight.py | 96 ++++++++++++++++++++++++++- apodex/tests/test_profiles.py | 4 ++ 4 files changed, 191 insertions(+), 5 deletions(-) diff --git a/apodex/config.py b/apodex/config.py index 7ba9bb1..f55e629 100644 --- a/apodex/config.py +++ b/apodex/config.py @@ -12,7 +12,7 @@ import os import re -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import urlsplit @@ -95,6 +95,65 @@ def ok(self) -> bool: _UNRESOLVED_ENV_RE = re.compile(r"\$(?:\{|[A-Z_])") +# Tools a closed-book run never binds. Named once here so the preflight cannot +# disagree with the runtime lists in ``workflows/stateful_react_agent/__init__.py`` +# and ``workflows/agent_team/__init__.py`` (both define the same frozenset as +# ``WEB_TOOL_NAMES``) or with the profile-override filtering in +# ``workflows/*/nodes/main_agent.py``. +CLOSED_BOOK_WEB_TOOLS = frozenset({"web_search", "web_fetch", "download_file"}) + +# Native workflow → the env flag that puts it in closed-book mode at runtime. +_WORKFLOW_CLOSED_BOOK_ENV = { + "stateful-react-agent": "REACT_NO_WEB", + "agent_team": "SWARM_NO_WEB", +} +# Terminal mode → same flag (``inspect_runtime_config`` knows both the profile's +# ``workflow`` and the active ``mode``; either one identifies the workflow). +_MODE_CLOSED_BOOK_ENV = { + "react": "REACT_NO_WEB", + "agent_team": "SWARM_NO_WEB", +} + +_CLOSED_BOOK_TRUTHY = ("1", "true", "yes", "on") + + +def _is_closed_book( + *, + workflow: str | None = None, + mode: str | None = None, + env: Mapping[str, str], +) -> bool: + """Whether the run drops web tools before they are bound. + + Mirrors the runtime checks (``REACT_NO_WEB`` / ``SWARM_NO_WEB``); the + ``.strip().lower()`` normalization matches ``nodes/main_agent.py``. + Unknown workflows/modes never count as closed-book: the generic loop has + no such gate, so its web tools still run. + """ + candidates = set() + if workflow in _WORKFLOW_CLOSED_BOOK_ENV: + candidates.add(_WORKFLOW_CLOSED_BOOK_ENV[workflow]) # type: ignore[index] + if mode in _MODE_CLOSED_BOOK_ENV: + candidates.add(_MODE_CLOSED_BOOK_ENV[mode]) # type: ignore[index] + return any( + str(env.get(var, "")).strip().lower() in _CLOSED_BOOK_TRUTHY + for var in candidates + ) + + +def apply_closed_book_filter( + tool_names: Iterable[str], + *, + workflow: str | None = None, + mode: str | None = None, + env: Mapping[str, str], +) -> frozenset[str]: + """Drop closed-book web tools from ``tool_names`` when the env requests it.""" + if _is_closed_book(workflow=workflow, mode=mode, env=env): + return frozenset(t for t in tool_names if t not in CLOSED_BOOK_WEB_TOOLS) + return frozenset(tool_names) + + def _configured(value: str | None) -> bool: stripped = (value or "").strip() return bool(stripped) and not _UNRESOLVED_ENV_RE.search(stripped) @@ -152,7 +211,17 @@ def inspect_runtime_config( # terminal only exposes ``react`` and ``agent_team``, and both bind # web_search and web_fetch, so a missing key first surfaced as an error # string inside a tool result. - tool_names = frozenset(getattr(profile, "tool_names", ()) or ()) + # + # Closed-book runs (REACT_NO_WEB / SWARM_NO_WEB) drop the web tools before + # they are bound, so filter them here too: warning about credentials for + # tools that will not run is a false positive. ``env`` is the same mapping + # used for the SERPER/JINA reads, so tests can drive this via ``environ=``. + tool_names = apply_closed_book_filter( + getattr(profile, "tool_names", ()) or (), + workflow=getattr(profile, "workflow", None), + mode=active_mode, + env=env, + ) if "web_search" in tool_names and not _configured(env.get("SERPER_API_KEY")): issues.append(RuntimeConfigIssue( code="missing_serper_api_key", @@ -275,10 +344,12 @@ def save(self) -> None: __all__ = [ + "CLOSED_BOOK_WEB_TOOLS", "ModelConfig", "RuntimeConfigIssue", "RuntimeConfigStatus", "UserSettings", + "apply_closed_book_filter", "format_preflight_errors", "format_runtime_config_status", "inspect_runtime_config", diff --git a/apodex/profiles/__init__.py b/apodex/profiles/__init__.py index 961c5a9..2c7f36b 100644 --- a/apodex/profiles/__init__.py +++ b/apodex/profiles/__init__.py @@ -27,7 +27,12 @@ from pathlib import Path from typing import Any -from apodex.config import ModelConfig, RuntimeConfigStatus, inspect_runtime_config +from apodex.config import ( + ModelConfig, + RuntimeConfigStatus, + apply_closed_book_filter, + inspect_runtime_config, +) from frontier_agent.infra.providers import environment_variable_source _PKG_DIR = Path(__file__).resolve().parent @@ -120,9 +125,21 @@ def tool_names(self) -> tuple[str, ...]: ``declared_tools``: dispatch hands the run to the native workflow, whose own profile carries the allowlists. Reading the top-level list would report on tools that are not bound and miss tools that are. + + Closed-book env flags (``REACT_NO_WEB`` / ``SWARM_NO_WEB``) drop the + web tools before they are bound at runtime, so they are dropped here + too — otherwise the preflight warns about credentials for tools that + will not run. The raw workflow read stays cached; only this filtering + re-reads the live environment per access. """ if self.workflow: - return _workflow_tool_names(self.workflow, self.workflow_profile or "") + raw = _workflow_tool_names(self.workflow, self.workflow_profile or "") + effective = apply_closed_book_filter( + raw, workflow=self.workflow, mode=self.name, env=os.environ, + ) + # Preserve the workflow profile's order, not the frozenset order. + keep = set(effective) + return tuple(t for t in raw if t in keep) return self.declared_tools def runtime_config( diff --git a/apodex/tests/test_config_preflight.py b/apodex/tests/test_config_preflight.py index 333876f..64e866d 100644 --- a/apodex/tests/test_config_preflight.py +++ b/apodex/tests/test_config_preflight.py @@ -116,7 +116,7 @@ def test_search_credentials_are_checked_when_the_profile_binds_the_web_tools(): assert no_web_tools.ok and not no_web_tools.warnings -def test_every_selectable_terminal_mode_preflights_its_search_credentials(): +def test_every_selectable_terminal_mode_preflights_its_search_credentials(monkeypatch): """The check used to key on ``research``, a mode the CLI cannot select. Both shipped modes bind web_search, so a blank SERPER_API_KEY reached the @@ -124,6 +124,10 @@ def test_every_selectable_terminal_mode_preflights_its_search_credentials(): """ from apodex.profiles import get_profile, terminal_mode_names + # Open-book baseline: closed-book flags would legitimately drop web_search. + monkeypatch.delenv("REACT_NO_WEB", raising=False) + monkeypatch.delenv("SWARM_NO_WEB", raising=False) + cfg = ModelConfig(model="gpt-test", api_key="secret", base_url="https://api.test/v1") modes = terminal_mode_names() assert modes, "the terminal must expose at least one mode" @@ -136,6 +140,96 @@ def test_every_selectable_terminal_mode_preflights_its_search_credentials(): assert status.ok, mode +def test_closed_book_env_suppresses_search_credential_warnings(): + """REACT_NO_WEB / SWARM_NO_WEB drop web tools at runtime, so the preflight + must not warn about credentials for tools that will not run.""" + cfg = ModelConfig(model="gpt-test", api_key="secret", base_url="https://api.test/v1") + + # Mode-routed: a workflow-less fake profile still filters via ``mode``. + react_closed = inspect_runtime_config( + cfg, + profile=_profile(tool_names=_WEB_TOOLS), + mode="react", + environ={"REACT_NO_WEB": "1"}, + ) + assert react_closed.ok and not react_closed.warnings + + swarm_closed = inspect_runtime_config( + cfg, + profile=_profile(tool_names=_WEB_TOOLS), + mode="agent_team", + environ={"SWARM_NO_WEB": "true"}, + ) + assert swarm_closed.ok and not swarm_closed.warnings + + # Flags are workflow-specific: the other workflow's flag changes nothing. + react_wrong_flag = inspect_runtime_config( + cfg, + profile=_profile(tool_names=_WEB_TOOLS), + mode="react", + environ={"SWARM_NO_WEB": "1"}, + ) + assert [i.code for i in react_wrong_flag.warnings] == [ + "missing_serper_api_key", "missing_jina_api_key", + ] + + swarm_wrong_flag = inspect_runtime_config( + cfg, + profile=_profile(tool_names=_WEB_TOOLS), + mode="agent_team", + environ={"REACT_NO_WEB": "1"}, + ) + assert [i.code for i in swarm_wrong_flag.warnings] == [ + "missing_serper_api_key", "missing_jina_api_key", + ] + + # Unknown modes have no closed-book gate: the generic loop still binds web + # tools, so the warnings stay even when a flag is set. + generic = inspect_runtime_config( + cfg, + profile=_profile(tool_names=_WEB_TOOLS), + mode="coding", + environ={"REACT_NO_WEB": "1", "SWARM_NO_WEB": "1"}, + ) + assert [i.code for i in generic.warnings] == [ + "missing_serper_api_key", "missing_jina_api_key", + ] + + +def test_closed_book_filtering_covers_both_shipped_modes(monkeypatch): + """End-to-end over the real workflow profiles: with the flag set, the + effective ``tool_names`` drop the web tools and the preflight stays quiet; + without it, both modes still warn.""" + import apodex.profiles as P + from apodex.profiles import get_profile + + P._CACHE.clear() + P._workflow_tool_names.cache_clear() + monkeypatch.delenv("REACT_NO_WEB", raising=False) + monkeypatch.delenv("SWARM_NO_WEB", raising=False) + + cfg = ModelConfig(model="gpt-test", api_key="secret", base_url="https://api.test/v1") + + for mode, flag in (("react", "REACT_NO_WEB"), ("agent_team", "SWARM_NO_WEB")): + profile = get_profile(mode) + # Open-book: web tools bound, credentials warned about. + assert "web_search" in profile.tool_names, mode + assert "web_fetch" in profile.tool_names, mode + status = inspect_runtime_config(cfg, profile=profile, mode=mode, environ={}) + assert "missing_serper_api_key" in [i.code for i in status.warnings], mode + assert "missing_jina_api_key" in [i.code for i in status.warnings], mode + + # Closed-book via the live environment (what ``tool_names`` reads). + monkeypatch.setenv(flag, "1") + try: + assert "web_search" not in profile.tool_names, mode + assert "web_fetch" not in profile.tool_names, mode + closed = inspect_runtime_config(cfg, profile=profile, mode=mode) + assert closed.ok and not closed.warnings, mode + finally: + monkeypatch.delenv(flag, raising=False) + + def test_cli_fails_before_session_construction_with_actionable_guidance( monkeypatch, capsys, ): diff --git a/apodex/tests/test_profiles.py b/apodex/tests/test_profiles.py index b52be51..491dfb5 100644 --- a/apodex/tests/test_profiles.py +++ b/apodex/tests/test_profiles.py @@ -117,6 +117,10 @@ def test_workflow_modes_expose_the_tools_their_workflow_profile_binds(monkeypatc monkeypatch.setenv("OPENAI_API_KEY", "test") monkeypatch.setenv("OPENAI_BASE_URL", "https://example.test/v1") monkeypatch.setenv("OPENAI_MODEL", "test-model") + # ``tool_names`` drops web tools under closed-book flags; this test pins + # the open-book union, so make sure the flags are off. + monkeypatch.delenv("REACT_NO_WEB", raising=False) + monkeypatch.delenv("SWARM_NO_WEB", raising=False) repo = pathlib.Path(P.__file__).resolve().parents[2] for mode in ("react", "agent_team"):