diff --git a/plugins/nemo-agents/examples/nemo-agent-config/README.md b/plugins/nemo-agents/examples/nemo-agent-config/README.md index e5b5529db5..f15d904b05 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/README.md +++ b/plugins/nemo-agents/examples/nemo-agent-config/README.md @@ -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..settings`. + ## Codex Authenticate Codex, leave `default_harness: codex` in `agent.yaml`, and run: diff --git a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml index e009ebe953..eb15cf8a28 100644 --- a/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml +++ b/plugins/nemo-agents/examples/nemo-agent-config/agent.yaml @@ -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 @@ -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 diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py index 45a78eb326..9c1319f404 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/agent_config.py @@ -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.""" @@ -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) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py index 17f3ecd841..0920db83b8 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -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), @@ -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) @@ -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: diff --git a/plugins/nemo-agents/tests/unit/test_agent_config.py b/plugins/nemo-agents/tests/unit/test_agent_config.py index fcd446df76..08c68af8ce 100644 --- a/plugins/nemo-agents/tests/unit/test_agent_config.py +++ b/plugins/nemo-agents/tests/unit/test_agent_config.py @@ -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( diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index c9f3883bac..4cb86fb63b 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -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"), [