Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apodex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
120 changes: 100 additions & 20 deletions apodex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
85 changes: 84 additions & 1 deletion apodex/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions apodex/profiles/agent_team.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
5 changes: 4 additions & 1 deletion apodex/profiles/react.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Loading
Loading