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/config.py b/apodex/config.py index 0687d03..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) @@ -147,25 +206,44 @@ 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. + # + # 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", + 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, @@ -266,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 7b54e35..2c7f36b 100644 --- a/apodex/profiles/__init__.py +++ b/apodex/profiles/__init__.py @@ -18,19 +18,71 @@ 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 -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 _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,6 +111,36 @@ class AgentProfile: # the lightweight terminal ReAct loop. workflow: str | None = None workflow_profile: str | None = None + # 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. + + 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: + 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( self, cfg: ModelConfig, *, mode: str | None = None, @@ -238,6 +320,7 @@ def _build(name: str) -> AgentProfile: models=models, system_prompt=system_prompt, tools=_tool_factory([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_config_preflight.py b/apodex/tests/test_config_preflight.py index 13546b8..64e866d 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,149 @@ 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"] + + 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(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 + model as an error string inside a tool result instead of failing preflight. + """ + 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" + 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_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", ] - coding = inspect_runtime_config(cfg, profile=_profile(), environ={}) - assert coding.ok and not coding.warnings + +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( diff --git a/apodex/tests/test_profiles.py b/apodex/tests/test_profiles.py index 112b7fe..491dfb5 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,77 @@ 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") + # ``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"): + 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)