// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! Configuration projected southbound to an adapter target. use std::collections::BTreeMap; use std::path::PathBuf; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::config::{ AdapterConfigField, AdapterDescriptor, CapabilityPlan, FabricConfig, InstructionMode, McpAuthenticationConfig, ResolvedAdapterTargetDescriptor, }; use crate::error::{FabricError, Result}; /// Configuration projected southbound to one adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentConfig { /// Adapter-owned target settings. #[serde(default, skip_serializing_if = "Option::is_none")] pub harness: Option, /// Named model roles applied by the adapter target. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub models: BTreeMap, /// Normalized instructions applied by the adapter target. #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option, /// Adapter-applied runtime behavior. #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime: Option, /// Skills made available to the adapter target. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option, /// MCP servers routed to the adapter target. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option, /// Named tool definitions and effective tool policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option, /// Custom agent or workflow selection and construction settings. #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow: Option, /// Adapter-owned fields validated against the selected adapter descriptor. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Adapter-owned target settings projected from `FabricConfig.harness`. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentHarnessConfig { /// Target-specific settings validated by the selected adapter descriptor. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, /// Adapter-owned harness fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Configuration for one named model role projected to an adapter target. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentModelConfig { /// Model provider identifier. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub provider: String, /// Provider model identifier. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub model: String, /// Environment variable containing the provider credential. #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key_env: Option, /// Optional model temperature. #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, /// Optional provider API base URL. #[serde(default, skip_serializing_if = "Option::is_none")] pub base_url: Option, /// Provider-specific model settings. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, /// Adapter-owned model fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// One normalized instruction value projected to an adapter target. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentInstructionConfig { /// Instruction text. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub content: String, /// How the instruction is applied. #[serde(default)] pub mode: InstructionMode, /// Adapter-owned instruction fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Normalized instructions projected to an adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentInstructionsConfig { /// System instructions for the selected adapter target. #[serde(default, skip_serializing_if = "Option::is_none")] pub system: Option, /// Adapter-owned instruction categories. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Runtime behavior applied by an adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentRuntimeConfig { /// Maximum number of agent turns allowed for one invocation. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = u32::MAX))] pub max_turns: Option, /// Adapter-owned runtime fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Skill paths made available to an adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentSkillConfig { /// Skill paths resolved for the task environment. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paths: Vec, /// Adapter-owned skill fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Named MCP servers routed to an adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentMcpConfig { /// MCP servers keyed by normalized server name. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub servers: BTreeMap, /// Adapter-owned MCP fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// One MCP server routed to an adapter target. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentMcpServerConfig { /// MCP transport identifier. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub transport: String, /// MCP server URL for network transports or executable for stdio. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub url: String, /// Command-line arguments passed to an MCP stdio process. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, /// Environment variables passed to an MCP stdio process. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env: BTreeMap, /// Authentication used by an HTTP MCP server. #[serde(default, skip_serializing_if = "Option::is_none")] pub authentication: Option, /// HTTP headers passed to an MCP server. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub custom_headers: BTreeMap, /// MCP tool names to expose. `None` exposes every discovered tool. #[serde(default, skip_serializing_if = "Option::is_none")] pub allowed_tools: Option>, /// MCP tool names blocked after applying the optional allowlist. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocked_tools: Vec, /// Adapter-owned MCP server fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// One named tool or tool-group definition resolved by an adapter. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentToolDefinition { /// Resolution semantics declared by the selected adapter descriptor. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub kind: String, /// Executable or factory reference interpreted under `kind`. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub r#ref: String, /// Definition-specific construction settings. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, /// Adapter-owned tool-definition fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Named tool definitions and effective adapter-target tool policy. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentToolsConfig { /// Tool and tool-group definitions keyed by normalized name. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub definitions: BTreeMap, /// Named tools to expose. `None` preserves the adapter-target default. #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option>, /// Named tools to block. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocked: Vec, /// Adapter-owned tool fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Adapter-declared resolution semantics for one custom agent or workflow. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentWorkflowEntrypointConfig { /// Resolution semantics declared by the selected adapter descriptor. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub kind: String, /// Executable or factory reference interpreted under `kind`. #[schemars(length(min = 1), regex(pattern = r"\S"))] pub r#ref: String, /// Adapter-owned entry-point fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Custom agent or workflow selection and construction settings. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AgentWorkflowConfig { /// Entry point resolved by the selected adapter. pub entrypoint: AgentWorkflowEntrypointConfig, /// Agent-specific construction settings. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, /// Adapter-owned workflow fields. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } /// Validate a projected adapter configuration before runtime handoff. pub(crate) fn validate_agent_config(config: &AgentConfig) -> Result<()> { for (role, model) in &config.models { require_non_blank( format!("agent_config.models.{role}.provider"), &model.provider, )?; require_non_blank(format!("agent_config.models.{role}.model"), &model.model)?; } if let Some(system) = config .instructions .as_ref() .and_then(|instructions| instructions.system.as_ref()) { require_non_blank("agent_config.instructions.system.content", &system.content)?; } if config .runtime .as_ref() .is_some_and(|runtime| runtime.max_turns == Some(0)) { return invalid_agent_config( "agent_config.runtime.max_turns", "must be greater than zero", ); } if let Some(mcp) = &config.mcp { for (name, server) in &mcp.servers { require_non_blank( format!("agent_config.mcp.servers.{name}.transport"), &server.transport, )?; require_non_blank(format!("agent_config.mcp.servers.{name}.url"), &server.url)?; } } if let Some(tools) = &config.tools { for (name, definition) in &tools.definitions { require_non_blank( format!("agent_config.tools.definitions.{name}.kind"), &definition.kind, )?; require_non_blank( format!("agent_config.tools.definitions.{name}.ref"), &definition.r#ref, )?; } } if let Some(workflow) = &config.workflow { require_non_blank( "agent_config.workflow.entrypoint.kind", &workflow.entrypoint.kind, )?; require_non_blank( "agent_config.workflow.entrypoint.ref", &workflow.entrypoint.r#ref, )?; } Ok(()) } fn require_non_blank(field: impl Into, value: &str) -> Result<()> { if value.trim().is_empty() { return invalid_agent_config(field, "must contain a non-whitespace character"); } Ok(()) } fn invalid_agent_config(field: impl Into, reason: impl Into) -> Result { Err(FabricError::InvalidConfig { field: field.into(), reason: reason.into(), }) } /// Project a resolved northbound config into the selected adapter target contract. pub(crate) fn project_agent_config( config: &FabricConfig, capability_plan: &CapabilityPlan, descriptor: Option<&AdapterDescriptor>, target: Option<&ResolvedAdapterTargetDescriptor>, ) -> AgentConfig { let accepts = |field: AdapterConfigField| { descriptor.is_some_and(|descriptor| descriptor.config.accepts.contains(&field)) }; let models = if accepts(AdapterConfigField::Models) { config .models .iter() .map(|(name, model)| { ( name.clone(), AgentModelConfig { provider: model.provider.clone(), model: model.model.clone(), api_key_env: model.api_key_env.clone(), temperature: model.temperature, base_url: model.base_url.clone(), settings: model.settings.clone(), extensions: model.extensions.clone(), }, ) }) .collect() } else { BTreeMap::new() }; let instructions = config.instructions.as_ref().and_then(|instructions| { let system = instructions.system.as_ref().and_then(|system| { accepts(AdapterConfigField::SystemInstructions).then(|| AgentInstructionConfig { content: system.content.clone(), mode: system.mode, extensions: system.extensions.clone(), }) }); (system.is_some() || !instructions.extensions.is_empty()).then(|| AgentInstructionsConfig { system, extensions: instructions.extensions.clone(), }) }); let max_turns = accepts(AdapterConfigField::MaxTurns) .then_some(config.runtime.max_turns) .flatten(); let runtime = (max_turns.is_some() || !config.runtime.extensions.is_empty()).then(|| { AgentRuntimeConfig { max_turns, extensions: config.runtime.extensions.clone(), } }); let skills = config.skills.as_ref().and_then(|skills| { (!capability_plan.native.skill_paths.is_empty() || !skills.extensions.is_empty()).then( || AgentSkillConfig { paths: capability_plan.native.skill_paths.clone(), extensions: skills.extensions.clone(), }, ) }); let mcp = config.mcp.as_ref().and_then(|mcp| { let servers = capability_plan .native .mcp_servers .iter() .map(|(name, server)| { ( name.clone(), AgentMcpServerConfig { transport: server.transport.as_str().to_string(), url: server.url.clone(), args: server.args.clone(), env: server.env.clone(), authentication: server.authentication.clone(), custom_headers: server.custom_headers.clone(), allowed_tools: server.allowed_tools.clone(), blocked_tools: server.blocked_tools.clone(), extensions: server.extensions.clone(), }, ) }) .collect::>(); (!servers.is_empty() || !mcp.extensions.is_empty()).then(|| AgentMcpConfig { servers, extensions: mcp.extensions.clone(), }) }); let tools = config.tools.as_ref().and_then(|tools| { let definitions = if accepts(AdapterConfigField::ToolDefinitions) { tools .definitions .iter() .map(|(name, definition)| { ( name.clone(), AgentToolDefinition { kind: definition.kind.clone(), r#ref: definition.r#ref.clone(), settings: definition.settings.clone(), extensions: definition.extensions.clone(), }, ) }) .collect() } else { BTreeMap::new() }; let enabled = accepts(AdapterConfigField::EnabledTools) .then(|| tools.enabled.clone()) .flatten(); let blocked = if accepts(AdapterConfigField::BlockedTools) { tools.blocked.clone() } else { Vec::new() }; (enabled.is_some() || !blocked.is_empty() || !definitions.is_empty() || !tools.extensions.is_empty()) .then(|| AgentToolsConfig { definitions, enabled, blocked, extensions: tools.extensions.clone(), }) }); let workflow = config .workflow .as_ref() .zip(target) .map(|(workflow, target)| { let entrypoint = &target.descriptor.target.workflow().entrypoint; AgentWorkflowConfig { entrypoint: AgentWorkflowEntrypointConfig { kind: entrypoint.kind.clone(), r#ref: entrypoint.r#ref.clone(), extensions: entrypoint.extensions.clone(), }, settings: workflow.settings.clone(), extensions: workflow.extensions.clone(), } }); let harness = config.harness.as_ref().and_then(|harness| { (!harness.settings.is_empty() || !harness.extensions.is_empty()).then(|| { AgentHarnessConfig { settings: harness.settings.clone(), extensions: harness.extensions.clone(), } }) }); AgentConfig { harness, models, instructions, runtime, skills, mcp, tools, workflow, extensions: config.extensions.clone(), } }