|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | +"""Registry of agent harnesses under ``responses_api_agents/<name>/``. |
| 16 | +
|
| 17 | +An *agent* is a directory ``responses_api_agents/<name>/`` providing an agent harness, with zero or |
| 18 | +more ``configs/*.yaml`` variants. This module maps an agent's short ``<name>`` (the directory name) |
| 19 | +to its config variant(s) so it can be referenced by name — the foundation for ``gym run --agent |
| 20 | +<name>`` (run-by-name) — and classifies whether the agent is freely *composable* with an arbitrary |
| 21 | +environment. |
| 22 | +
|
| 23 | +- **Composable (Pattern A):** the agent references a *separate* resources server |
| 24 | + (``responses_api_agents.<type>.resources_server``), so it can be paired with any environment. |
| 25 | +- **Not composable (Pattern B):** the agent is self-contained — it declares an ``agent_framework`` |
| 26 | + or bakes in its own environment/external LLM harness (e.g. ``swe_agents``, ``harbor_agent``, |
| 27 | + ``verifiers_agent``, ``claude_code_agent``) — and cannot be dropped onto an arbitrary environment. |
| 28 | +
|
| 29 | +Discovery only reads config files; it never resolves interpolations or missing values and never |
| 30 | +starts servers, so it is safe to call when secrets/API keys referenced by a config are unset. |
| 31 | +""" |
| 32 | + |
| 33 | +from dataclasses import dataclass |
| 34 | +from difflib import get_close_matches |
| 35 | +from pathlib import Path |
| 36 | +from typing import Dict, List, Optional, Tuple |
| 37 | + |
| 38 | +from omegaconf import OmegaConf |
| 39 | + |
| 40 | +from nemo_gym import PARENT_DIR |
| 41 | + |
| 42 | + |
| 43 | +AGENTS_DIR = PARENT_DIR / "responses_api_agents" |
| 44 | +AGENT_CONFIGS_SUBDIR = "configs" |
| 45 | + |
| 46 | + |
| 47 | +class AgentNotFoundError(ValueError): |
| 48 | + """An agent was referenced by a name that is not registered under ``responses_api_agents/``.""" |
| 49 | + |
| 50 | + |
| 51 | +class AgentVariantError(ValueError): |
| 52 | + """An agent has no standalone config, or has several and no variant was given to disambiguate.""" |
| 53 | + |
| 54 | + |
| 55 | +class AgentNotComposableError(ValueError): |
| 56 | + """A self-contained (Pattern B) agent was requested for free composition with an environment.""" |
| 57 | + |
| 58 | + |
| 59 | +@dataclass(frozen=True) |
| 60 | +class AgentEntry: |
| 61 | + """A discovered agent: its name, where it lives, its config variants, and composability.""" |
| 62 | + |
| 63 | + name: str |
| 64 | + path: Path |
| 65 | + config_paths: Tuple[Path, ...] # variant config files, sorted; empty for "zero-config" agents |
| 66 | + composable: bool |
| 67 | + description: Optional[str] = None |
| 68 | + |
| 69 | + @property |
| 70 | + def variants(self) -> Dict[str, Path]: |
| 71 | + """Map variant name (config filename stem) -> config path.""" |
| 72 | + return {path.stem: path for path in self.config_paths} |
| 73 | + |
| 74 | + |
| 75 | +def _iter_agent_blocks(config_path: Path): |
| 76 | + """Yield each ``responses_api_agents.<type>`` mapping in a config (resolution-safe, best effort).""" |
| 77 | + try: |
| 78 | + container = OmegaConf.to_container(OmegaConf.load(config_path), resolve=False, throw_on_missing=False) |
| 79 | + except Exception: |
| 80 | + return |
| 81 | + if not isinstance(container, dict): |
| 82 | + return |
| 83 | + for top_level_value in container.values(): |
| 84 | + if not isinstance(top_level_value, dict): |
| 85 | + continue |
| 86 | + agents = top_level_value.get("responses_api_agents") |
| 87 | + if not isinstance(agents, dict): |
| 88 | + continue |
| 89 | + for agent_block in agents.values(): |
| 90 | + if isinstance(agent_block, dict): |
| 91 | + yield agent_block |
| 92 | + |
| 93 | + |
| 94 | +def _is_agent_config(config_path: Path) -> bool: |
| 95 | + """True if the file is a NeMo Gym agent config (a top-level block with ``responses_api_agents``). |
| 96 | +
|
| 97 | + Filters out non-agent YAML that happens to live in an agent's ``configs/`` dir (e.g. a raw |
| 98 | + harness config or an empty stub). |
| 99 | + """ |
| 100 | + return next(_iter_agent_blocks(config_path), None) is not None |
| 101 | + |
| 102 | + |
| 103 | +def _classify(config_paths: Tuple[Path, ...]) -> Tuple[bool, Optional[str]]: |
| 104 | + """Return ``(composable, description)`` for an agent from its config variants. |
| 105 | +
|
| 106 | + Composable iff some variant references a separate resources server, none declares an |
| 107 | + ``agent_framework``, and none drives an external LLM harness (e.g. its own Anthropic key). |
| 108 | + Agents with no parseable config default to composable (their wiring lives in a paired |
| 109 | + benchmark/resources-server config). |
| 110 | + """ |
| 111 | + has_resources_server = False |
| 112 | + has_agent_framework = False |
| 113 | + drives_external_harness = False |
| 114 | + description: Optional[str] = None |
| 115 | + |
| 116 | + saw_block = False |
| 117 | + for config_path in config_paths: |
| 118 | + for block in _iter_agent_blocks(config_path): |
| 119 | + saw_block = True |
| 120 | + if "resources_server" in block: |
| 121 | + has_resources_server = True |
| 122 | + if "agent_framework" in block: |
| 123 | + has_agent_framework = True |
| 124 | + if "anthropic_api_key" in block: |
| 125 | + drives_external_harness = True |
| 126 | + if description is None and isinstance(block.get("description"), str): |
| 127 | + description = block["description"] |
| 128 | + |
| 129 | + if not saw_block: |
| 130 | + return True, description |
| 131 | + composable = has_resources_server and not has_agent_framework and not drives_external_harness |
| 132 | + return composable, description |
| 133 | + |
| 134 | + |
| 135 | +def discover_agents(agents_dir: Path = AGENTS_DIR) -> Dict[str, AgentEntry]: |
| 136 | + """Map agent name -> :class:`AgentEntry` for every agent dir under ``responses_api_agents/``. |
| 137 | +
|
| 138 | + The name is the directory name. A directory is an agent if it has an ``app.py`` or at least one |
| 139 | + agent config. Returns an empty dict if the directory is missing. |
| 140 | + """ |
| 141 | + agents: Dict[str, AgentEntry] = {} |
| 142 | + if not agents_dir.is_dir(): |
| 143 | + return agents |
| 144 | + |
| 145 | + for child in sorted(agents_dir.iterdir()): |
| 146 | + if not child.is_dir(): |
| 147 | + continue |
| 148 | + configs_dir = child / AGENT_CONFIGS_SUBDIR |
| 149 | + config_files = sorted(configs_dir.glob("*.yaml")) if configs_dir.is_dir() else [] |
| 150 | + agent_configs = tuple(path for path in config_files if _is_agent_config(path)) |
| 151 | + if not (child / "app.py").is_file() and not agent_configs: |
| 152 | + continue |
| 153 | + |
| 154 | + composable, description = _classify(agent_configs) |
| 155 | + agents[child.name] = AgentEntry( |
| 156 | + name=child.name, |
| 157 | + path=child, |
| 158 | + config_paths=agent_configs, |
| 159 | + composable=composable, |
| 160 | + description=description, |
| 161 | + ) |
| 162 | + |
| 163 | + return agents |
| 164 | + |
| 165 | + |
| 166 | +def _did_you_mean(name: str, available: List[str], noun: str) -> str: |
| 167 | + suggestions = get_close_matches(name, available, n=3, cutoff=0.6) |
| 168 | + if suggestions: |
| 169 | + return "Did you mean: " + ", ".join(repr(s) for s in suggestions) + "?" |
| 170 | + return f"Available {noun}: " + (", ".join(repr(n) for n in available) or "(none)") |
| 171 | + |
| 172 | + |
| 173 | +def resolve_agent_config_path( |
| 174 | + name: str, |
| 175 | + variant: Optional[str] = None, |
| 176 | + agents_dir: Path = AGENTS_DIR, |
| 177 | + require_composable: bool = False, |
| 178 | +) -> str: |
| 179 | + """Return the config path to load to run agent ``name`` — the run-by-name primitive. |
| 180 | +
|
| 181 | + Selection: an explicit ``variant`` wins; otherwise a single config is used directly, and a |
| 182 | + variant whose name equals ``name`` is the default when several exist. Raises |
| 183 | + :class:`AgentNotFoundError` (with a "did you mean?" hint) for an unknown agent, |
| 184 | + :class:`AgentVariantError` for a zero-config or ambiguous-variant agent, and — when |
| 185 | + ``require_composable`` is set — :class:`AgentNotComposableError` for a Pattern B agent. |
| 186 | + """ |
| 187 | + agents = discover_agents(agents_dir) |
| 188 | + entry = agents.get(name) |
| 189 | + if entry is None: |
| 190 | + raise AgentNotFoundError( |
| 191 | + f"No agent named '{name}' under {agents_dir}.\n{_did_you_mean(name, sorted(agents), 'agents')}" |
| 192 | + ) |
| 193 | + |
| 194 | + if require_composable and not entry.composable: |
| 195 | + raise AgentNotComposableError( |
| 196 | + f"Agent '{name}' is self-contained (it bakes in its own environment/framework) and cannot " |
| 197 | + "be freely composed with an arbitrary environment; run it with its own config instead." |
| 198 | + ) |
| 199 | + |
| 200 | + variants = entry.variants |
| 201 | + if not variants: |
| 202 | + raise AgentVariantError( |
| 203 | + f"Agent '{name}' ships no standalone config; it is composed via its paired " |
| 204 | + "benchmark/resources-server config." |
| 205 | + ) |
| 206 | + |
| 207 | + if variant is not None: |
| 208 | + if variant not in variants: |
| 209 | + raise AgentVariantError( |
| 210 | + f"Agent '{name}' has no variant '{variant}'.\n{_did_you_mean(variant, sorted(variants), 'variants')}" |
| 211 | + ) |
| 212 | + return str(variants[variant]) |
| 213 | + |
| 214 | + if len(variants) == 1: |
| 215 | + return str(next(iter(variants.values()))) |
| 216 | + if name in variants: |
| 217 | + return str(variants[name]) |
| 218 | + raise AgentVariantError( |
| 219 | + f"Agent '{name}' has multiple config variants: {sorted(variants)}; pass a variant to select one." |
| 220 | + ) |
0 commit comments