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
4 changes: 4 additions & 0 deletions plugins/nemo-agents/examples/nemo-agent-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Install them explicitly before local Fabric smoke tests:
uv pip install -e "plugins/nemo-agents[fabric]"
```

Top-level `skills`, `mcp`, and `tools` are Platform-owned shared fields that
translate into `FabricConfig`. Prompt settings are harness-specific for now and
should be configured under `harnesses.<name>.settings`.

## Codex

Authenticate Codex, leave `default_harness: codex` in `agent.yaml`, and run:
Expand Down
11 changes: 8 additions & 3 deletions plugins/nemo-agents/examples/nemo-agent-config/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ harnesses:
kind: codex
settings:
sandbox: workspace-write
system_prompt: You are a concise test assistant.
config_overrides:
model_reasoning_effort: high

Expand All @@ -33,10 +34,14 @@ models:
provider: openai
model: openai/gpt-5.4

prompts:
system: prompts/system.md

skills:
paths: []

mcp:
servers: {}

tools:
blocked: []

environment:
workspace: ./workspace
Expand Down
30 changes: 29 additions & 1 deletion plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@ class TelemetryConfig(BaseModel):
atof: dict[str, Any] | None = None


class SkillsConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

paths: list[str] = Field(default_factory=list)


class McpServerConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

transport: str
url: str
exposure: Literal["harness_native", "fabric_managed"] = "harness_native"


class McpConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

servers: dict[str, McpServerConfig] = Field(default_factory=dict)


class ToolsConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

blocked: list[str] = Field(default_factory=list)


class AgentConfig(BaseModel):
"""Platform-owned agent.yaml config for nemo-agents-spec-v1."""

Expand All @@ -74,7 +100,9 @@ class AgentConfig(BaseModel):
harnesses: dict[str, HarnessConfig]
models: dict[str, ModelConfig] = Field(default_factory=dict)
prompts: dict[str, str] = Field(default_factory=dict)
skills: dict[str, Any] | list[Any] | None = None
skills: SkillsConfig | None = None
mcp: McpConfig | None = None
tools: ToolsConfig | None = None
environment: EnvironmentConfig = Field(default_factory=EnvironmentConfig)
telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)

Expand Down
31 changes: 31 additions & 0 deletions plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None)
"""Translate Platform-owned agent config into a typed in-memory FabricConfig."""
selected_harness_name, harness = _select_harness(config, harness_name)
model = _resolve_model(config, selected_harness_name, harness)
_validate_untranslated_shared_fields(config)

fabric_config = fabric.FabricConfig(
metadata=fabric.MetadataConfig(name=config.name, description=config.description or None),
Expand All @@ -44,6 +45,9 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None)
artifacts=config.environment.artifacts,
settings=config.environment.settings,
),
skills=_skills_config(config),
mcp=_mcp_config(config),
tools=_tools_config(config),
)

_apply_telemetry(fabric_config, config, model)
Expand Down Expand Up @@ -85,6 +89,33 @@ def _model_payload(model: ModelConfig) -> dict[str, Any]:
return model.model_dump(exclude_none=True)


def _validate_untranslated_shared_fields(config: AgentConfig) -> None:
if config.prompts:
raise FabricTranslationError(
"Top-level prompts are not translated yet. Configure prompt settings under the selected harness instead."
)


def _skills_config(config: AgentConfig) -> Any:
if config.skills is None:
return None
return fabric.SkillConfig(paths=config.skills.paths)


def _mcp_config(config: AgentConfig) -> Any:
if config.mcp is None:
return None
return fabric.McpConfig(
servers={name: fabric.McpServerConfig(**server.model_dump()) for name, server in config.mcp.servers.items()}
)


def _tools_config(config: AgentConfig) -> Any:
if config.tools is None:
return None
return fabric.ToolsConfig(blocked=config.tools.blocked)


def _apply_telemetry(fabric_config: Any, config: AgentConfig, model: ModelConfig) -> None:
telemetry = config.telemetry
if not telemetry.enabled:
Expand Down
26 changes: 26 additions & 0 deletions plugins/nemo-agents/tests/unit/test_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,37 @@ def test_defaults_fill_optional_sections(self) -> None:
assert config.description == ""
assert config.models == {}
assert config.prompts == {}
assert config.skills is None
assert config.mcp is None
assert config.tools is None
assert config.environment.provider == "local"
assert config.environment.workspace == "./workspace"
assert config.environment.artifacts == "./artifacts"
assert config.telemetry.enabled is False

def test_shared_capability_sections_validate(self) -> None:
payload = _example_yaml_config()
payload["skills"] = {"paths": ["skills/review"]}
payload["mcp"] = {
"servers": {
"repo": {
"transport": "stdio",
"url": "repo-mcp --root .",
}
}
}
payload["tools"] = {"blocked": ["shell", "browser"]}

config = AgentConfig.model_validate(payload)

assert config.skills is not None
assert config.skills.paths == ["skills/review"]
assert config.mcp is not None
assert config.mcp.servers["repo"].transport == "stdio"
assert config.mcp.servers["repo"].exposure == "harness_native"
assert config.tools is not None
assert config.tools.blocked == ["shell", "browser"]

def test_default_harness_must_reference_configured_harness(self) -> None:
with pytest.raises(ValidationError, match="default_harness must reference one of harnesses: codex"):
AgentConfig.model_validate(
Expand Down
31 changes: 31 additions & 0 deletions plugins/nemo-agents/tests/unit/test_fabric_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,37 @@ def test_selected_harness_uses_default_model(self) -> None:
assert fabric_config.models["default"].provider == "openai"
assert fabric_config.models["default"].model == "openai/gpt-5.4"

def test_translates_shared_capability_sections(self) -> None:
payload = copy.deepcopy(_example_yaml_config())
payload["skills"] = {"paths": ["skills/review"]}
payload["mcp"] = {
"servers": {
"repo": {
"transport": "stdio",
"url": "repo-mcp --root .",
"exposure": "fabric_managed",
}
}
}
payload["tools"] = {"blocked": ["shell", "browser"]}
config = AgentConfig.model_validate(payload)

fabric_config = translate_agent_config(config)

assert fabric_config.skills.paths == ["skills/review"]
assert fabric_config.mcp.servers["repo"].transport == "stdio"
assert fabric_config.mcp.servers["repo"].url == "repo-mcp --root ."
assert fabric_config.mcp.servers["repo"].exposure == "fabric_managed"
assert fabric_config.tools.blocked == ["shell", "browser"]

def test_top_level_prompts_rejected_until_shared_prompt_contract_exists(self) -> None:
payload = copy.deepcopy(_example_yaml_config())
payload["prompts"] = {"system": "prompts/system.md"}
config = AgentConfig.model_validate(payload)

with pytest.raises(FabricTranslationError, match="Top-level prompts are not translated yet"):
translate_agent_config(config)

@pytest.mark.parametrize(
("kind", "adapter_id"),
[
Expand Down