Skip to content

Commit d14dc18

Browse files
committed
feat: agent registry — name-based agent discovery + composability (M3 core)
Add nemo_gym.agent_registry, mirroring the environment registry: discover agents under responses_api_agents/<name>/, resolve an agent name (+ optional variant) to its config — the run-by-name primitive for 'gym run --agent <name>' — and classify each agent as composable (Pattern A: references a separate resources server) vs self-contained (Pattern B: agent_framework / external harness, e.g. swe_agents, harbor_agent, claude_code_agent). Resolution-safe (reads configs only), with did-you-mean hints, variant selection, and AgentNotComposableError for the composer. Foundation for the M3 config_composer (epic #1205, friction #6); CLI wiring deferred to align with the unified CLI. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
1 parent 89c2009 commit d14dc18

2 files changed

Lines changed: 446 additions & 0 deletions

File tree

nemo_gym/agent_registry.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)