diff --git a/.coderabbit.yaml b/.coderabbit.yaml index d4d2f752b..6822eb066 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -54,7 +54,7 @@ reviews: - path: "crates/fabric-core/src/**/*.rs" instructions: | Review the Rust core for runtime lifecycle correctness, handle validation, capability routing accuracy, schema stability, and error semantics. - Public API changes should preserve existing behavior unless tests and docs show the intended migration path. + Public API changes should match committed schemas, tests, and documentation. - path: "crates/fabric-python/**/*" instructions: | Treat native binding changes as public API changes. Check JSON/type conversion, error propagation, GIL/thread behavior, and parity with the Python SDK. diff --git a/POC-TO-MVP-PLAN.md b/POC-TO-MVP-PLAN.md index de293b89e..9cd438cce 100644 --- a/POC-TO-MVP-PLAN.md +++ b/POC-TO-MVP-PLAN.md @@ -231,8 +231,8 @@ Status: - Base Python SDK and CLI surfaces are in place. - SDK supports agent-package paths and typed/in-memory config. - CLI supports validate, inspect, plan, doctor, schema generation, and run. -- SDK session APIs cover `start`, `start_config`, `invoke`, `stream`, `cancel`, - and `stop` for `runtime.mode: session`, including caller-provided +- SDK session APIs cover `start_session`, `invoke`, `stream`, `cancel`, and + `stop` for `runtime.mode: session`, including caller-provided `session_id` propagation. - CLI includes `fabric chat` for local interactive session-mode debugging with explicit `--session-id`, `/info`, `/verbose`, and oneshot-profile rejection. diff --git a/README.md b/README.md index b556b5866..9e881ce70 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ async def main(): agent = Path("examples/code-review-agent") async with FabricClient() as client: - plan = client.plan(agent, profile="hermes_sdk") - report = await client.doctor(agent, profile="hermes_sdk") + plan = client.plan(agent, profiles=["hermes_sdk"]) + report = await client.doctor(agent, profiles=["hermes_sdk"]) print(plan["agent_name"]) print(report["checks"]) @@ -152,7 +152,9 @@ Consumers that already own a top-level job config can construct the Fabric slice in code instead of materializing an agent directory: ```python -plan = client.plan_config( +from nemo_fabric import FabricClient, FabricConfig + +config = FabricConfig.from_mapping( { "schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "code-review-agent"}, @@ -170,10 +172,54 @@ plan = client.plan_config( "output_schema": "message", }, }, +) + +client = FabricClient() +plan = client.plan( + config, base_dir="examples/code-review-agent", ) ``` +For runtime invocation, callers can either pass simple text or construct the +request explicitly. Results remain dict-compatible while exposing stable fields +as attributes: + +```python +from nemo_fabric import FabricClient, FabricConfig, FabricError, RunRequest + +request = RunRequest( + input="Review the workspace changes.", + request_id="job-123-turn-1", + context={"job_id": "job-123"}, + overrides={"max_iterations": 1}, +) + +async def run(raw_config): + config = FabricConfig.from_mapping(raw_config) + try: + async with FabricClient() as client: + result = await client.run( + config, + base_dir="examples/code-review-agent", + request=request, + ) + except FabricError as error: + print(error.stage, error.code, error.retryable) + raise + + print(result.status) + print(result["runtime_id"]) +``` + +`RunRequest.from_mapping(...)` accepts JSON-shaped request dictionaries when +callers load or compose requests outside the SDK. Per-request `context` is +caller-owned metadata; `overrides` are +request-scoped config changes applied only where the selected harness adapter +supports them. Failed runs expose structured `result.error.stage`, +`result.error.code`, and `result.error.retryable` when the adapter returns a +normalized failure. + ### Multi-Turn SDK Sessions Open a `Session` and invoke it repeatedly. The session keeps one Fabric runtime @@ -181,10 +227,10 @@ handle active across turns; harness/adapter state is authoritative rather than reconstructed from a Python-side transcript. Fabric separates runtime identity from conversation identity. Each -`start(...)`/`start_config(...)` call creates a new `runtime_id` for that -runtime lifecycle. `session_id` is the stable conversation key used for resume: -if omitted, Fabric uses the generated `runtime_id`; if supplied, Fabric uses the -caller-provided `session_id`. +`start_session(...)` call creates a new `runtime_id` for that runtime lifecycle. +`session_id` is the stable conversation key used for resume: if omitted, Fabric +uses the generated `runtime_id`; if supplied, Fabric uses the caller-provided +`session_id`. ```python import asyncio @@ -192,23 +238,25 @@ import asyncio from nemo_fabric import FabricClient async def chat(): - async with await FabricClient().start( + async with await FabricClient().start_session( "examples/code-review-agent", - profile="hermes_session", + profiles=["hermes_session"], session_id="review-session-123", ) as session: - await session.invoke("My name is Robin.") - reply = await session.invoke("What's my name?") # recalls "Robin" + await session.invoke(input="My name is Robin.") + reply = await session.invoke(input="What's my name?") # recalls "Robin" print(session.runtime_id, session.session_id, session.status.value) print(reply["output"]["response"]) asyncio.run(chat()) ``` -Sessions require the native binding; `start_config(...)` is the typed-config -equivalent. `stream(...)` yields events then the final result (buffered today); -`cancel()` cooperatively aborts an in-flight turn. Session APIs require -`runtime.mode: session`. +`start_session(...)` accepts either an agent path with named profiles or a +`FabricConfig` with typed profiles. `stream(...)` is the stable streaming API; +current adapters may buffer internally before yielding events and the final +result. Runtime updates and cancellation are capability-gated and raise +`FabricCapabilityError` when the selected runtime does not support them. +Session APIs require `runtime.mode: session`. ### Interactive CLI Chat @@ -238,17 +286,12 @@ transcript and metadata are written together on stderr. The real-Hermes integration check is `tests/smoke_hermes_session.py`. -When installed from the repository root, `FabricClient()` uses the native Rust -binding. SDK `run(...)`, `start(...)`, and their typed-config equivalents all -drive the core Fabric runtime lifecycle (`start_runtime` / `invoke_runtime` / -`stop_runtime`) so one-shot and session paths use the same adapter execution -contract. - -For source-tree debugging, pass an explicit CLI command: - -```python -client = FabricClient(command=("cargo", "run", "-q", "-p", "fabric-cli", "--")) -``` +`FabricClient()` uses the native Rust binding. SDK `run(...)` and +`start_session(...)` drive the core Fabric runtime lifecycle (`start_runtime` / +`invoke_runtime` / `stop_runtime`) so one-shot and session paths use the same +adapter execution contract. The CLI is a separate interface over the same Rust +core. For source-tree development, install the package with +`python3 -m pip install -e .` before using the SDK. ## Other Runs diff --git a/adapters/hermes-cli/fabric-adapter.json b/adapters/hermes-cli/fabric-adapter.json index a0566827b..79db372a2 100644 --- a/adapters/hermes-cli/fabric-adapter.json +++ b/adapters/hermes-cli/fabric-adapter.json @@ -1,5 +1,6 @@ { "adapter_id": "nvidia.fabric.hermes.cli", + "harness": "hermes", "adapter_kind": "process", "runner": { "command": "python3", diff --git a/adapters/hermes-sdk/fabric-adapter.json b/adapters/hermes-sdk/fabric-adapter.json index 4c03b5dc6..6643a05b9 100644 --- a/adapters/hermes-sdk/fabric-adapter.json +++ b/adapters/hermes-sdk/fabric-adapter.json @@ -1,5 +1,6 @@ { "adapter_id": "nvidia.fabric.hermes.sdk", + "harness": "hermes", "adapter_kind": "python", "runner": { "module": "nemo_fabric_adapters.hermes_sdk.adapter", diff --git a/crates/fabric-cli/src/main.rs b/crates/fabric-cli/src/main.rs index 7bf56151f..203365025 100644 --- a/crates/fabric-cli/src/main.rs +++ b/crates/fabric-cli/src/main.rs @@ -378,7 +378,7 @@ fn print_chat_info( eprintln!("+----------------------------------------------------------------+"); eprintln!("| agent: {}", plan.agent_name); eprintln!("| profile: {}", profile_label(plan)); - eprintln!("| harness: {}", runtime.harness_type); + eprintln!("| harness: {}", runtime.harness); eprintln!("| adapter: {}", adapter_kind_label(runtime.adapter_kind)); eprintln!("| runtime_id: {}", runtime.runtime_id); eprintln!( @@ -449,9 +449,7 @@ fn profile_label(plan: &RunPlan) -> String { if !plan.profiles.is_empty() { return plan.profiles.join(", "); } - plan.profile - .clone() - .unwrap_or_else(|| "default".to_string()) + "default".to_string() } fn adapter_kind_label(adapter_kind: AdapterKind) -> &'static str { diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 85821f6a1..149a8464b 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -3,11 +3,12 @@ //! Fabric config models and loading helpers. +use std::borrow::Cow; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use schemars::JsonSchema; +use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -62,6 +63,9 @@ pub struct FabricConfig { /// Optional profile discovery config. #[serde(default, skip_serializing_if = "ProfileRegistryConfig::is_empty")] pub profiles: ProfileRegistryConfig, + /// Additive fields not yet recognized by this core version. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Profile discovery config for curated package profiles. @@ -70,11 +74,14 @@ pub struct ProfileRegistryConfig { /// Directories searched when a caller selects a profile by name. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub directories: Vec, + /// Additive profile-discovery fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } impl ProfileRegistryConfig { fn is_empty(&self) -> bool { - self.directories.is_empty() + self.directories.is_empty() && self.extensions.is_empty() } } @@ -86,6 +93,9 @@ pub struct MetadataConfig { /// Optional description. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// Additive metadata fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Harness selection. @@ -99,13 +109,20 @@ pub struct HarnessConfig { /// Harness-specific settings. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, + /// Additive normalized harness fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Language-neutral adapter descriptor for a harness integration. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterDescriptor { /// Unique id for this adapter implementation. + #[schemars(length(min = 1))] pub adapter_id: String, + /// Stable machine-readable harness identifier implemented by this adapter. + #[schemars(length(min = 1))] + pub harness: String, /// Adapter implementation kind. pub adapter_kind: AdapterKind, /// Generic runner defaults consumed by the selected runtime adapter. @@ -120,6 +137,9 @@ pub struct AdapterDescriptor { /// Telemetry support declared by this adapter. #[serde(default)] pub telemetry: AdapterTelemetrySupport, + /// Additive adapter descriptor fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Where Fabric resolved an adapter descriptor from. @@ -308,6 +328,9 @@ pub struct AdapterRequirements { /// Required harness plugin hooks. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub plugin_hooks: Vec, + /// Additive requirement fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Adapter config support. @@ -319,6 +342,9 @@ pub struct AdapterConfigSupport { /// Harness-native files generated by this adapter. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub generates: Vec, + /// Additive adapter config-support fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Adapter telemetry support. @@ -327,10 +353,13 @@ pub struct AdapterTelemetrySupport { /// Telemetry outputs supported by this adapter. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub supports: Vec, + /// Additive adapter telemetry fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Profile config applied on top of a Fabric config. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct ProfileConfig { /// Optional profile schema version. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -341,30 +370,49 @@ pub struct ProfileConfig { /// Optional profile description. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - /// Harness overrides. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub harness: Option, - /// Model aliases to add or replace. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub models: BTreeMap, - /// Runtime override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runtime: Option, - /// Environment override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment: Option, - /// Tool capability override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, - /// Skill capability override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skills: Option, - /// MCP capability override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp: Option, - /// Telemetry override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub telemetry: Option, + /// Raw config fields recursively merged over the base config. + #[serde(default, flatten)] + pub overlay: BTreeMap, +} + +#[derive(JsonSchema)] +#[allow(dead_code)] +struct ProfileConfigSchema { + /// Optional profile schema version. + schema_version: Option, + /// Optional profile name used for directory discovery. + name: Option, + /// Optional profile description. + description: Option, + /// Partial harness overlay. + harness: Option>, + /// Partial model overlays by alias. + models: Option>, + /// Partial runtime overlay. + runtime: Option>, + /// Partial environment overlay. + environment: Option>, + /// Tool capability overlay. + tools: Option, + /// Partial skill overlay. + skills: Option>, + /// Partial MCP overlay. + mcp: Option>, + /// Partial telemetry overlay. + telemetry: Option>, + /// Additive config overlays. + #[serde(flatten)] + extensions: BTreeMap, +} + +impl JsonSchema for ProfileConfig { + fn schema_name() -> Cow<'static, str> { + "ProfileConfig".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ProfileConfigSchema::json_schema(generator) + } } /// Source context used when resolving an in-memory Fabric config. @@ -430,6 +478,9 @@ pub struct ModelConfig { /// Provider-specific settings. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, + /// Additive normalized model fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Runtime mode and input/output contract. @@ -438,14 +489,32 @@ pub struct RuntimeConfig { /// Runtime mode. pub mode: RuntimeMode, /// Transport used to operate the harness. + #[serde(default = "default_runtime_transport")] pub transport: Transport, /// Input schema label. + #[serde(default = "default_input_schema")] pub input_schema: String, /// Output schema label. + #[serde(default = "default_output_schema")] pub output_schema: String, /// Artifact directory. #[serde(default, skip_serializing_if = "Option::is_none")] pub artifacts: Option, + /// Additive normalized runtime fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +fn default_runtime_transport() -> Transport { + Transport::Library +} + +fn default_input_schema() -> String { + "text".to_string() +} + +fn default_output_schema() -> String { + "text".to_string() } /// Runtime lifecycle mode. @@ -500,6 +569,9 @@ pub struct EnvironmentConfig { /// Provider-specific settings. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub settings: serde_json::Map, + /// Additive normalized environment fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } fn default_control_location() -> ControlLocation { @@ -516,6 +588,9 @@ pub struct SkillConfig { /// Skill paths relative to the agent root. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paths: Vec, + /// Additive skill fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// MCP capability configuration. @@ -524,6 +599,9 @@ pub struct McpConfig { /// Named MCP servers. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub servers: BTreeMap, + /// Additive MCP fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// MCP server configuration. @@ -535,6 +613,9 @@ pub struct McpServerConfig { pub url: String, /// How Fabric exposes the MCP capability to the harness. pub exposure: McpExposure, + /// Additive MCP server fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// MCP exposure strategy. @@ -565,6 +646,9 @@ pub struct TelemetryConfig { /// Pass-through telemetry backend config. #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, + /// Additive telemetry fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, } /// Load a Fabric document from an agent directory or single agent config. @@ -618,12 +702,12 @@ pub fn resolve_effective_config_with_profiles( let agent_name = config.metadata.name.clone(); let (profile_configs, selected_profiles) = load_profile_configs(&agent_name, &config, &root, profiles)?; - Ok(resolve_effective_config_from_config_with_profile_names( + resolve_effective_config_from_config_with_profile_names( config, &profile_configs, selected_profiles, ResolveContext::from_config_path(path, root), - )) + ) } } } @@ -633,7 +717,7 @@ pub fn resolve_effective_config_from_config( config: FabricConfig, profiles: &[ProfileConfig], context: ResolveContext, -) -> EffectiveConfig { +) -> Result { let selected_profiles = profiles .iter() .enumerate() @@ -694,7 +778,7 @@ pub fn resolve_run_plan_from_config( ) -> Result { resolve_run_plan_from_effective_config(resolve_effective_config_from_config( config, profiles, context, - )) + )?) } fn load_fabric_config(path: &Path) -> Result { @@ -778,26 +862,20 @@ fn resolve_effective_config_from_config_with_profile_names( profiles: &[ProfileConfig], selected_profiles: Vec, context: ResolveContext, -) -> EffectiveConfig { +) -> Result { let mut effective = config; for profile in profiles { - apply_profile_config(&mut effective, profile); + apply_profile_config(&mut effective, profile)?; } let config = into_effective_config(effective); - let profile = if selected_profiles.len() == 1 { - selected_profiles.first().cloned() - } else { - None - }; - EffectiveConfig { + Ok(EffectiveConfig { agent_name: config.metadata.name.clone(), - profile, profiles: selected_profiles, agent_root: context.agent_root, config_path: context.config_path, config_root: context.config_root, config, - } + }) } /// Resolve execution planning metadata from merged effective config. @@ -815,15 +893,16 @@ pub fn resolve_run_plan_from_effective_config( validate_control_location(descriptor, environment_plan.as_ref())?; let capability_plan = resolve_capability_plan(&config, &config_root, adapter_descriptor.as_ref()); + let capabilities = resolve_runtime_capabilities(&config, descriptor); let telemetry_plan = resolve_telemetry_plan(&config, descriptor); Ok(RunPlan { agent_name: effective_config.agent_name.clone(), - profile: effective_config.profile.clone(), profiles: effective_config.profiles.clone(), adapter_descriptor, resolution, environment_plan, capability_plan, + capabilities, telemetry_plan, agent_root: effective_config.agent_root.clone(), config_path: effective_config.config_path.clone(), @@ -906,30 +985,26 @@ fn is_yaml_file(path: &Path) -> bool { .is_some_and(|extension| matches!(extension, "yaml" | "yml")) } -fn apply_profile_config(config: &mut FabricConfig, profile: &ProfileConfig) { - if let Some(harness) = &profile.harness { - config.harness = harness.clone(); - } - for (name, model) in &profile.models { - config.models.insert(name.clone(), model.clone()); - } - if let Some(runtime) = &profile.runtime { - config.runtime = runtime.clone(); - } - if let Some(environment) = &profile.environment { - config.environment = Some(environment.clone()); - } - if let Some(tools) = &profile.tools { - config.tools = Some(tools.clone()); - } - if let Some(skills) = &profile.skills { - config.skills = Some(skills.clone()); - } - if let Some(mcp) = &profile.mcp { - config.mcp = Some(mcp.clone()); - } - if let Some(telemetry) = &profile.telemetry { - config.telemetry = Some(telemetry.clone()); +fn apply_profile_config(config: &mut FabricConfig, profile: &ProfileConfig) -> Result<()> { + let mut merged = serde_json::to_value(&*config).map_err(FabricError::SerializeJson)?; + let overlay = Value::Object(profile.overlay.clone().into_iter().collect()); + merge_json(&mut merged, overlay); + *config = serde_json::from_value(merged).map_err(FabricError::SerializeJson)?; + Ok(()) +} + +fn merge_json(base: &mut Value, overlay: Value) { + match (base, overlay) { + (Value::Object(base), Value::Object(overlay)) => { + for (key, value) in overlay { + if let Some(current) = base.get_mut(&key) { + merge_json(current, value); + } else { + base.insert(key, value); + } + } + } + (base, overlay) => *base = overlay, } } @@ -975,6 +1050,9 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path if descriptor.adapter_id.trim().is_empty() { return invalid_adapter_descriptor(path, "adapter_id must not be empty"); } + if descriptor.harness.trim().is_empty() { + return invalid_adapter_descriptor(path, "harness must not be empty"); + } Ok(()) } @@ -992,6 +1070,30 @@ fn validate_control_location( Ok(()) } +fn resolve_runtime_capabilities( + config: &FabricConfig, + descriptor: Option<&AdapterDescriptor>, +) -> RuntimeCapabilities { + let implemented_runtime = descriptor.is_some_and(|descriptor| { + matches!( + descriptor.adapter_kind, + AdapterKind::Process | AdapterKind::Python + ) + }) && matches!( + config.runtime.transport, + Transport::Library | Transport::Cli + ); + RuntimeCapabilities { + session: implemented_runtime && config.runtime.mode == RuntimeMode::Session, + service: false, + streaming: false, + updates: false, + cancellation: false, + concurrent_invocations: false, + metadata: BTreeMap::new(), + } +} + fn resolve_resolution( config: &FabricConfig, _adapter_descriptor: Option<&AdapterDescriptor>, @@ -1199,11 +1301,7 @@ fn normalize_path(path: PathBuf) -> PathBuf { pub struct EffectiveConfig { /// Stable agent name. pub agent_name: String, - /// Selected profile when exactly one profile is applied. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, /// Ordered selected profiles. - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub profiles: Vec, /// Root used to resolve agent package paths. pub agent_root: PathBuf, @@ -1222,11 +1320,7 @@ pub struct RunPlan { pub effective_config: EffectiveConfig, /// Stable agent name. pub agent_name: String, - /// Selected profile when exactly one profile is applied. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, /// Ordered selected profiles. - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub profiles: Vec, /// Adapter descriptor resolved for this plan, when configured. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1240,6 +1334,8 @@ pub struct RunPlan { /// Resolved capability configuration. #[serde(default)] pub capability_plan: CapabilityPlan, + /// Lifecycle behavior implemented by the selected runtime path. + pub capabilities: RuntimeCapabilities, /// Resolved telemetry pass-through plan. #[serde(default, skip_serializing_if = "Option::is_none")] pub telemetry_plan: Option, @@ -1253,6 +1349,26 @@ pub struct RunPlan { pub config: FabricConfig, } +/// Lifecycle behavior implemented by a resolved runtime path. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RuntimeCapabilities { + /// Whether the selected runtime supports session lifecycle operations. + pub session: bool, + /// Whether the selected runtime supports service lifecycle operations. + pub service: bool, + /// Whether invocations can emit progressive output. + pub streaming: bool, + /// Whether a running runtime can accept config updates. + pub updates: bool, + /// Whether an in-flight invocation can be cancelled. + pub cancellation: bool, + /// Whether the runtime accepts concurrent invocations. + pub concurrent_invocations: bool, + /// Additional adapter-specific capability metadata. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, +} + /// Resolved environment plan. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct EnvironmentPlan { @@ -1401,13 +1517,132 @@ mod tests { repository_adapter_dir().join("hermes-sdk/fabric-adapter.json") } + #[test] + fn typed_profiles_merge_partial_objects_and_preserve_extensions() { + let config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes.sdk + settings: + workspace: ./workspace +runtime: + mode: oneshot + transport: library + input_schema: chat + output_schema: message +tools: + enabled: [base] + cleared: + value: true +future_top_level: + base: true + nested: + first: 1 +"#, + ) + .expect("base config"); + let profile: ProfileConfig = serde_yaml::from_str( + r#" +schema_version: fabric.profile/v1alpha1 +name: session +harness: + settings: + timeout_seconds: 30 +runtime: + mode: session +tools: + enabled: [profile] + cleared: null +future_top_level: + profile: true + nested: + second: 2 +"#, + ) + .expect("partial profile"); + + let effective = resolve_effective_config_from_config( + config, + &[profile], + ResolveContext::from_agent_root("."), + ) + .expect("effective config"); + let value = serde_json::to_value(&effective.config).expect("config json"); + + assert_eq!(effective.profiles, ["session"]); + assert_eq!(value["runtime"]["mode"], "session"); + assert_eq!(value["runtime"]["transport"], "library"); + assert_eq!(value["harness"]["settings"]["workspace"], "./workspace"); + assert_eq!(value["harness"]["settings"]["timeout_seconds"], 30); + assert_eq!(value["tools"]["enabled"], serde_json::json!(["profile"])); + assert!(value["tools"]["cleared"].is_null()); + assert_eq!(value["future_top_level"]["base"], true); + assert_eq!(value["future_top_level"]["profile"], true); + assert_eq!(value["future_top_level"]["nested"]["first"], 1); + assert_eq!(value["future_top_level"]["nested"]["second"], 2); + } + + #[test] + fn runtime_uses_stable_defaults_for_omitted_optional_fields() { + let config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes.sdk +runtime: + mode: oneshot +"#, + ) + .expect("minimal config"); + + assert_eq!(config.runtime.transport, Transport::Library); + assert_eq!(config.runtime.input_schema, "text"); + assert_eq!(config.runtime.output_schema, "text"); + } + + #[test] + fn unsupported_transports_do_not_claim_session_capability() { + let mut config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes.sdk +runtime: + mode: session +"#, + ) + .expect("session config"); + let descriptor = + load_adapter_descriptor(example_adapter_descriptor_path()).expect("adapter descriptor"); + + assert!(resolve_runtime_capabilities(&config, Some(&descriptor)).session); + for transport in [Transport::Http, Transport::NativePlugin] { + config.runtime.transport = transport; + assert!(!resolve_runtime_capabilities(&config, Some(&descriptor)).session); + } + } + #[test] fn loads_adapter_descriptor() { let descriptor = load_adapter_descriptor(example_adapter_descriptor_path()).expect("adapter descriptor"); assert_eq!(descriptor.adapter_id, "nvidia.fabric.hermes.sdk"); + assert_eq!(descriptor.harness, "hermes"); assert_eq!(descriptor.adapter_kind, AdapterKind::Python); + let descriptor_json = serde_json::to_value(&descriptor).expect("descriptor json"); + assert_eq!( + descriptor_json.get("harness").and_then(Value::as_str), + Some("hermes") + ); + assert!(descriptor_json.get("harness_type").is_none()); assert_eq!( descriptor.runner.get("module").and_then(Value::as_str), Some("nemo_fabric_adapters.hermes_sdk.adapter") @@ -1425,7 +1660,20 @@ mod tests { let plan = resolve_run_plan(example_agent_dir(), None).expect("run plan"); assert_eq!(plan.agent_name, "code-review-agent"); - assert_eq!(plan.profile.as_deref(), None); + assert!(plan.profiles.is_empty()); + let plan_json = serde_json::to_value(&plan).expect("plan json"); + assert_eq!(plan_json["profiles"], serde_json::json!([])); + assert_eq!( + plan_json["capabilities"], + serde_json::json!({ + "session": true, + "service": false, + "streaming": false, + "updates": false, + "cancellation": false, + "concurrent_invocations": false + }) + ); assert_eq!(plan.config.harness.adapter_id, "nvidia.fabric.hermes.sdk"); assert_eq!( plan.adapter_descriptor @@ -1518,6 +1766,7 @@ environment: root.join("adapters/reviewer-process/fabric-adapter.json"), r#"{ "adapter_id": "acme.fabric.reviewer.process", + "harness": "reviewer", "adapter_kind": "process" }"#, ) @@ -1544,7 +1793,7 @@ environment: let plan = resolve_run_plan(example_agent_dir(), Some("env_opensandbox")).expect("run plan"); - assert_eq!(plan.profile.as_deref(), Some("env_opensandbox")); + assert_eq!(plan.profiles, vec!["env_opensandbox"]); assert!(plan.config_path.ends_with("agent.yaml")); assert_eq!( plan.config @@ -1559,8 +1808,9 @@ environment: fn resolves_mcp_profile_from_agent_directory() { let plan = resolve_run_plan(example_agent_dir(), Some("mcp_github")).expect("run plan"); - assert_eq!(plan.profile.as_deref(), Some("mcp_github")); assert_eq!(plan.profiles, vec!["mcp_github"]); + let plan_json = serde_json::to_value(&plan).expect("plan json"); + assert!(plan_json.get("profile").is_none()); assert_eq!( plan.config.mcp.as_ref().map(|mcp| mcp.servers.len()), Some(1) @@ -1586,7 +1836,6 @@ environment: let plan = resolve_run_plan_with_profiles(example_agent_dir(), &profiles).expect("run plan"); - assert_eq!(plan.profile, None); assert_eq!(plan.profiles, profiles); assert_eq!( plan.environment_plan @@ -1623,7 +1872,6 @@ environment: ) .expect("run plan"); - assert_eq!(plan.profile.as_deref(), Some("mcp_github")); assert_eq!(plan.profiles, vec!["mcp_github"]); assert!(plan.config_path.ends_with("agent.yaml")); assert!(plan.config.profiles.directories.is_empty()); @@ -1680,6 +1928,7 @@ mcp: root.join("adapters/minimal/fabric-adapter.json"), r#"{ "adapter_id": "acme.fabric.minimal", + "harness": "minimal", "adapter_kind": "process" }"#, ) @@ -1716,7 +1965,6 @@ mcp: let plan = resolve_run_plan_with_profiles(example_agent_dir(), &profiles).expect("run plan"); - assert_eq!(plan.profile, None); assert_eq!(plan.profiles, profiles); assert_eq!( plan.environment_plan @@ -1760,7 +2008,6 @@ mcp: fn resolves_hermes_sdk_profile_from_agent_directory() { let plan = resolve_run_plan(example_agent_dir(), Some("hermes_sdk")).expect("run plan"); - assert_eq!(plan.profile.as_deref(), Some("hermes_sdk")); assert_eq!(plan.profiles, vec!["hermes_sdk"]); assert_eq!(plan.config.harness.adapter_id, "nvidia.fabric.hermes.sdk"); assert_eq!( @@ -1804,7 +2051,7 @@ mcp: let plan = resolve_run_plan(example_agent_dir(), Some("./profiles/hermes-sdk.yaml")) .expect("run plan"); - assert_eq!(plan.profile.as_deref(), Some("./profiles/hermes-sdk.yaml")); + assert_eq!(plan.profiles, vec!["./profiles/hermes-sdk.yaml"]); assert_eq!(plan.config.harness.adapter_id, "nvidia.fabric.hermes.sdk"); assert_eq!( plan.adapter_descriptor @@ -1854,6 +2101,7 @@ environment: root.join("adapters/invalid-process/fabric-adapter.json"), r#"{ "adapter_id": " ", + "harness": "invalid", "adapter_kind": "process" }"#, ) @@ -1882,6 +2130,7 @@ environment: &descriptor_path, r#"{ "adapter_id": "", + "harness": "invalid", "adapter_kind": "process" }"#, ) diff --git a/crates/fabric-core/src/doctor.rs b/crates/fabric-core/src/doctor.rs index 016fd150c..5da804fe5 100644 --- a/crates/fabric-core/src/doctor.rs +++ b/crates/fabric-core/src/doctor.rs @@ -46,9 +46,8 @@ pub struct DoctorCheck { pub struct DoctorReport { /// Agent name. pub agent_name: String, - /// Selected profile. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, + /// Ordered profiles applied to the inspected plan. + pub profiles: Vec, /// Overall status. pub status: DoctorStatus, /// Checks. @@ -69,7 +68,7 @@ pub fn doctor_plan(plan: &RunPlan) -> DoctorReport { }); DoctorReport { agent_name: plan.agent_name.clone(), - profile: plan.profile.clone(), + profiles: plan.profiles.clone(), status, checks, } @@ -534,6 +533,12 @@ mod tests { let report = doctor_plan(&plan); assert_eq!(report.status, DoctorStatus::Warn); + let report_json = serde_json::to_value(&report).expect("doctor report json"); + assert!(report_json.get("profile").is_none()); + assert_eq!( + report_json["profiles"], + serde_json::json!(["env_opensandbox"]) + ); assert!(report.checks.iter().any(|check| { check.name == "requirements.environment" && check.message.contains("opensandbox") })); diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index 65be0bd63..921738c3d 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -15,8 +15,8 @@ pub use config::{ EnvironmentConfig, EnvironmentOwnership, EnvironmentPlan, FabricConfig, FabricDocument, HarnessConfig, McpConfig, McpExposure, McpServerPlan, MetadataConfig, ModelConfig, ProfileConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, RunPlan, - RuntimeConfig, RuntimeMode, SkillConfig, TelemetryConfig, TelemetryPlan, Transport, - load_adapter_descriptor, load_fabric_document, resolve_effective_config, + RuntimeCapabilities, RuntimeConfig, RuntimeMode, SkillConfig, TelemetryConfig, TelemetryPlan, + Transport, load_adapter_descriptor, load_fabric_document, resolve_effective_config, resolve_effective_config_from_config, resolve_effective_config_with_profiles, resolve_run_plan, resolve_run_plan_from_config, resolve_run_plan_from_effective_config, resolve_run_plan_with_profiles, validate_agent_directory, diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 03dfda4aa..9828135a4 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -59,11 +59,10 @@ impl RunRequest { pub struct RunResult { /// Stable agent name. pub agent_name: String, - /// Selected profile name when loaded through an agent manifest. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - /// Harness type used for this run. - pub harness_type: String, + /// Ordered profiles applied to this run. + pub profiles: Vec, + /// Stable machine-readable harness identifier used for this run. + pub harness: String, /// Adapter used for this run. pub adapter_kind: AdapterKind, /// Adapter implementation id when an adapter descriptor was resolved. @@ -233,8 +232,8 @@ pub struct RuntimeHandle { pub runtime_binding: String, /// Agent name. pub agent_name: String, - /// Harness type. - pub harness_type: String, + /// Stable machine-readable harness identifier. + pub harness: String, /// Runtime mode. pub mode: RuntimeMode, /// Adapter kind. @@ -417,7 +416,7 @@ pub fn start_runtime(plan: &RunPlan) -> Result { AdapterKind::Process => ProcessAdapter.start(plan, environment), AdapterKind::Python => PythonAdapter.start(plan, environment), adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { - harness: harness_type(plan), + harness: harness(plan), adapter_kind, }), } @@ -434,7 +433,7 @@ pub fn invoke_runtime( AdapterKind::Process => ProcessAdapter.invoke(plan, runtime, request), AdapterKind::Python => PythonAdapter.invoke(plan, runtime, request), adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { - harness: harness_type(plan), + harness: harness(plan), adapter_kind, }), } @@ -447,7 +446,7 @@ pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result ProcessAdapter.stop(runtime), AdapterKind::Python => PythonAdapter.stop(runtime), adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { - harness: runtime.harness_type.clone(), + harness: runtime.harness.clone(), adapter_kind, }), } @@ -462,12 +461,7 @@ fn validate_runtime_handle(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<() &runtime.runtime_binding, )?; expect_runtime_field(runtime, "agent_name", &plan.agent_name, &runtime.agent_name)?; - expect_runtime_field( - runtime, - "harness_type", - &harness_type(plan), - &runtime.harness_type, - )?; + expect_runtime_field(runtime, "harness", &harness(plan), &runtime.harness)?; expect_runtime_field( runtime, "runtime.mode", @@ -615,7 +609,7 @@ impl RuntimeAdapter for ProcessAdapter { runtime_id, runtime_binding, agent_name: plan.agent_name.clone(), - harness_type: harness_type(plan), + harness: harness(plan), mode: plan.config.runtime.mode, adapter_kind: adapter_kind(plan), adapter_id: adapter_id(plan), @@ -663,7 +657,7 @@ impl RuntimeAdapter for PythonAdapter { runtime_id, runtime_binding, agent_name: plan.agent_name.clone(), - harness_type: harness_type(plan), + harness: harness(plan), mode: plan.config.runtime.mode, adapter_kind: adapter_kind(plan), adapter_id: adapter_id(plan), @@ -772,7 +766,7 @@ fn run_process_adapter( )]; events.push(event_with_metadata( "invocation_start", - format!("starting process adapter for {}", harness_type(plan)), + format!("starting process adapter for {}", harness(plan)), BTreeMap::from([ ( "runtime_id".to_string(), @@ -899,8 +893,8 @@ fn run_process_adapter( Ok(RunResult { agent_name: plan.agent_name.clone(), - profile: plan.profile.clone(), - harness_type: harness_type(plan), + profiles: plan.profiles.clone(), + harness: harness(plan), adapter_kind: adapter_kind(plan), adapter_id: adapter_id(plan), runtime_id: invocation.runtime_id, @@ -977,7 +971,7 @@ fn run_python_adapter( )]; events.push(event_with_metadata( "invocation_start", - format!("starting python adapter for {}", harness_type(plan)), + format!("starting python adapter for {}", harness(plan)), BTreeMap::from([ ( "runtime_id".to_string(), @@ -1110,8 +1104,8 @@ fn run_python_adapter( Ok(RunResult { agent_name: plan.agent_name.clone(), - profile: plan.profile.clone(), - harness_type: harness_type(plan), + profiles: plan.profiles.clone(), + harness: harness(plan), adapter_kind: adapter_kind(plan), adapter_id: adapter_id(plan), runtime_id: invocation.runtime_id, @@ -1145,8 +1139,11 @@ fn write_child_stdin(stdin: &mut impl Write, payload: &str, command: &str) -> Re } } -fn harness_type(plan: &RunPlan) -> String { - adapter_id(plan).unwrap_or_else(|| "unknown".to_string()) +fn harness(plan: &RunPlan) -> String { + plan.adapter_descriptor + .as_ref() + .map(|adapter| adapter.descriptor.harness.clone()) + .unwrap_or_else(|| "unknown".to_string()) } fn adapter_kind(plan: &RunPlan) -> AdapterKind { @@ -1716,8 +1713,8 @@ fn prepare_relay_runtime_config( }, "fabric": { "agent_name": plan.agent_name.clone(), - "profile": plan.profile.clone(), - "harness_type": harness_type(plan), + "profiles": plan.profiles.clone(), + "harness": harness(plan), "adapter_id": adapter_id(plan), "runtime_id": runtime.runtime_id.clone(), "invocation_id": invocation.invocation_id.clone(), @@ -1888,6 +1885,7 @@ runtime: fn process_adapter_descriptor() -> &'static str { r#"{ "adapter_id": "acme.fabric.process", + "harness": "process", "adapter_kind": "process" }"# } @@ -1979,12 +1977,19 @@ environment: #[test] fn process_adapter_passes_input_to_stdin() { let root = temp_process_agent_dir(); - let plan = resolve_run_plan(&root, None).expect("run plan"); + let mut plan = resolve_run_plan(&root, None).expect("run plan"); + plan.profiles = vec!["runtime".to_string(), "telemetry".to_string()]; let result = run_plan(&plan, RunRequest::text("hello fabric")).expect("run result"); assert_eq!(result.status, RunStatus::Succeeded); assert_eq!(result.output, Value::String("hello fabric".to_string())); assert_eq!(result.metadata.get("exit_code"), Some(&Value::from(0))); + let result_json = serde_json::to_value(&result).expect("result json"); + assert!(result_json.get("profile").is_none()); + assert_eq!( + result_json["profiles"], + serde_json::json!(["runtime", "telemetry"]) + ); assert_eq!(artifact_content(&result, "stdout"), "hello fabric"); let _ = fs::remove_dir_all(root); @@ -2157,7 +2162,7 @@ environment: let root = temp_process_agent_dir(); let plan = resolve_run_plan(&root, None).expect("run plan"); let mut runtime = start_runtime(&plan).expect("runtime"); - runtime.harness_type = "other-harness".to_string(); + runtime.harness = "other-harness".to_string(); let error = invoke_runtime(&plan, &runtime, RunRequest::text("hello fabric")) .expect_err("runtime mismatch"); diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index 92b36e6b6..316d9c99f 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -223,4 +223,12 @@ mod tests { ); } } + + #[test] + fn adapter_descriptor_schema_rejects_empty_identifiers() { + let schema = generate_schema(SchemaName::AdapterDescriptor).expect("schema generation"); + + assert_eq!(schema["properties"]["adapter_id"]["minLength"], 1); + assert_eq!(schema["properties"]["harness"]["minLength"], 1); + } } diff --git a/crates/fabric-python/src/lib.rs b/crates/fabric-python/src/lib.rs index cad9a362b..7b3f0bb8a 100644 --- a/crates/fabric-python/src/lib.rs +++ b/crates/fabric-python/src/lib.rs @@ -7,7 +7,8 @@ use std::path::PathBuf; use fabric_core::{ FabricConfig, ProfileConfig, ResolveContext, RunPlan, RunRequest, RuntimeHandle, doctor_plan, - load_fabric_document, resolve_effective_config_with_profiles, resolve_run_plan_from_config, + load_fabric_document, resolve_effective_config_from_config, + resolve_effective_config_with_profiles, resolve_run_plan_from_config, resolve_run_plan_with_profiles, run_plan, }; use pyo3::exceptions::PyRuntimeError; @@ -36,6 +37,22 @@ fn inspect(py: Python<'_>, path: String, profile: Option>) -> PyResult to_json(&effective_config) } +/// Resolve typed config/profile JSON into effective config and return JSON. +#[pyfunction] +#[pyo3(signature = (config_json, profiles_json=None, base_dir=None))] +fn resolve_config( + config_json: String, + profiles_json: Option, + base_dir: Option, +) -> PyResult { + let config = parse_config(config_json)?; + let profiles = parse_profiles(profiles_json)?; + let effective = + resolve_effective_config_from_config(config, &profiles, resolve_context(base_dir)) + .map_err(to_py_error)?; + to_json(&effective) +} + /// Resolve an agent/profile into a runnable plan and return JSON. #[pyfunction] #[pyo3(signature = (path, profile=None))] @@ -201,6 +218,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(version, m)?)?; m.add_function(wrap_pyfunction!(validate, m)?)?; m.add_function(wrap_pyfunction!(inspect, m)?)?; + m.add_function(wrap_pyfunction!(resolve_config, m)?)?; m.add_function(wrap_pyfunction!(plan, m)?)?; m.add_function(wrap_pyfunction!(plan_config, m)?)?; m.add_function(wrap_pyfunction!(doctor, m)?)?; diff --git a/docs/python-sdk-contract.md b/docs/python-sdk-contract.md new file mode 100644 index 000000000..b544b0fcd --- /dev/null +++ b/docs/python-sdk-contract.md @@ -0,0 +1,562 @@ +# Python SDK Contract + +## Scope and Status + +This is the target public API. MVP includes typed sources, resolution, planning, +diagnostics, oneshot runs, sessions, typed results and errors, capability checks, +and a stable buffered `stream()` shape. Runtime updates, progressive streaming, +and service mode may follow MVP. Unsupported operations raise +`FabricCapabilityError`. + +## Design + +Fabric owns runtime execution; callers own orchestration, servers, tenancy, +persistence, and product workflows. The SDK uses one source abstraction, one +ordered `profiles` argument, and one method per lifecycle operation. + +## Common Types + +All values crossing the Python/native boundary are JSON-shaped. + +```python +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator, Mapping, Sequence +from pathlib import Path +from typing import Literal, overload + +JSONScalar = str | int | float | bool | None +JSONValue = JSONScalar | list["JSONValue"] | dict[str, "JSONValue"] +PathSource = str | os.PathLike[str] +AgentSource = PathSource | FabricConfig +``` + +Invalid JSON values raise `FabricConfigError` before native execution. + +## Client and CLI + +```python +class FabricClient: + def __init__(self) -> None: ... +``` + +`FabricClient` is native-only. The CLI is a separate surface over the same core; +the same file-backed config and profiles produce equivalent contract data. + +## Agent Sources and Profiles + +Profile types follow the agent source: + +```python +@overload +def plan( + agent: PathSource, + *, + profiles: str | Sequence[str] | None = None, +) -> RunPlan: ... + +@overload +def plan( + agent: FabricConfig, + *, + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, +) -> RunPlan: ... +``` + +The same overload pattern applies to `resolve`, `doctor`, `run`, +`start_session`, and `start_service`. + +- Agent strings are paths, never raw config, adapter IDs, or agent names. +- Paths accept one profile name or an ordered sequence of names. `FabricConfig` + uses ordered `FabricProfileConfig` objects; mixed stacks are rejected. +- `base_dir` applies only to `FabricConfig`. +- Raw mappings require explicit `from_mapping(...)` conversion. +- Equivalent file and typed sources produce equivalent configs and plans. + +There is no public singular `profile` alias or public `plan_config`, +`run_config`, `doctor_config`, `start`, or `start_config` family. + +## Typed Config + +Typed config uses the same schema as `agent.yaml`. + +```python +class MetadataConfig: + name: str + description: str | None + extra_fields: Mapping[str, JSONValue] + +class HarnessConfig: + adapter_id: str + resolution: str | None + settings: Mapping[str, JSONValue] + extra_fields: Mapping[str, JSONValue] + +class RuntimeConfig: + mode: Literal["oneshot", "session", "service"] + transport: str | None + input_schema: str | None + output_schema: str | None + artifacts: str | Path | None + extra_fields: Mapping[str, JSONValue] + +class EnvironmentConfig: + provider: str + workspace: str | Path | None + artifacts: str | Path | None + settings: Mapping[str, JSONValue] + metadata: Mapping[str, JSONValue] + extra_fields: Mapping[str, JSONValue] + +class FabricConfig: + schema_version: str + metadata: MetadataConfig + harness: HarnessConfig + runtime: RuntimeConfig + environment: EnvironmentConfig | None + models: Mapping[str, Mapping[str, JSONValue]] + mcp: Mapping[str, JSONValue] | None + skills: Mapping[str, JSONValue] | None + telemetry: Mapping[str, JSONValue] | None + profiles: Mapping[str, JSONValue] | None + tools: JSONValue + extra_fields: Mapping[str, JSONValue] + + @classmethod + def from_mapping(cls, value: Mapping[str, JSONValue]) -> FabricConfig: ... + + def to_mapping(self) -> dict[str, JSONValue]: ... + +class FabricProfileConfig: + schema_version: str + name: str + description: str | None + harness: HarnessConfig | Mapping[str, JSONValue] | None + runtime: RuntimeConfig | Mapping[str, JSONValue] | None + environment: EnvironmentConfig | Mapping[str, JSONValue] | None + models: Mapping[str, Mapping[str, JSONValue]] | None + mcp: Mapping[str, JSONValue] | None + skills: Mapping[str, JSONValue] | None + telemetry: Mapping[str, JSONValue] | None + tools: JSONValue + extra_fields: Mapping[str, JSONValue] + + @classmethod + def from_mapping( + cls, + value: Mapping[str, JSONValue], + ) -> FabricProfileConfig: ... + + def to_mapping(self) -> dict[str, JSONValue]: ... +``` + +- `metadata` and `harness` are required; names, adapter IDs, and runtime mode + are validated. +- Mutable configs default to the v1alpha1 schemas and `oneshot`; omitted + environment, runtime transport, and schemas remain unset until resolution, + which applies local, `library`, `text`, and `text` defaults. +- Constructors reject unknown keywords. Mapping conversion preserves unknown + fields through `extra_fields` and returns deep copies. +- Profile sections are partial recursive overlays. They are validated as a + complete config after merging with the base and earlier profiles. +- Config is mutable before resolution; plans and runtimes are snapshots. +- Unstable model, MCP, skill, telemetry, and tool shapes remain JSON mappings. +- `FabricConfig.profiles` controls discovery; lifecycle `profiles` selects + overlays. + +## Config Extension + +Normalized fields represent cross-harness concepts. Adapter-only fields belong +in `HarnessConfig.settings`. Unknown fields are preserved but are not supported +until the SDK recognizes them. + +## Inspection Types + +Inspection and result models are typed, read-only mappings that preserve unknown +fields. + +```python +class AdapterInfo: + adapter_id: str + harness: str + adapter_kind: str + metadata: Mapping[str, JSONValue] + +class RuntimeCapabilities: + session: bool + service: bool + streaming: bool + updates: bool + cancellation: bool + concurrent_invocations: bool + metadata: Mapping[str, JSONValue] + +class EffectiveConfig: + agent_name: str + profiles: Sequence[str] + agent_root: Path + config_path: Path | None + config_root: Path + config: FabricConfig + +class RunPlan: + effective_config: EffectiveConfig + agent_name: str + profiles: Sequence[str] + adapter: AdapterInfo + capabilities: RuntimeCapabilities + +class DoctorCheck: + name: str + status: Literal["pass", "warn", "fail"] + message: str + metadata: Mapping[str, JSONValue] + +class DoctorReport: + agent_name: str + profiles: Sequence[str] + status: Literal["pass", "warn", "fail"] + checks: Sequence[DoctorCheck] +``` + +`harness` is the stable machine-readable harness identifier. `adapter_id` +identifies its Fabric adapter implementation, while `adapter_kind` identifies +the execution mechanism. + +## Client API + +These compact signatures use the source-specific overloads above. + +```python +class FabricClient: + def resolve( + self, + agent: AgentSource, + *, + profiles: str | Sequence[str] | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> EffectiveConfig: ... + + def plan( + self, + agent: AgentSource, + *, + profiles: str | Sequence[str] | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> RunPlan: ... + + async def doctor( + self, + agent: AgentSource, + *, + profiles: str | Sequence[str] | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> DoctorReport: ... + + async def run( + self, + agent: AgentSource, + *, + profiles: str | Sequence[str] | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + input: JSONValue = None, + input_file: str | Path | None = None, + request: RunRequest | Mapping[str, JSONValue] | None = None, + request_file: str | Path | None = None, + request_id: str | None = None, + context: Mapping[str, JSONValue] | None = None, + overrides: Mapping[str, JSONValue] | None = None, + ) -> RunResult: ... + + async def start_session( + self, + agent: AgentSource, + *, + profiles: str | Sequence[str] | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + session_id: str | None = None, + overrides: Mapping[str, JSONValue] | None = None, + ) -> Session: ... + + async def start_service( + self, + agent: AgentSource, + *, + profiles: str | Sequence[str] | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + service_id: str | None = None, + overrides: Mapping[str, JSONValue] | None = None, + ) -> RuntimeService: ... +``` + +`resolve()` resolves config only; `plan()` resolves adapters and capabilities. + +## Requests and Overrides + +```python +class RunRequest: + input: JSONValue + request_id: str + context: Mapping[str, JSONValue] + overrides: Mapping[str, JSONValue] | None + extra_fields: Mapping[str, JSONValue] + + @classmethod + def from_mapping( + cls, + value: Mapping[str, JSONValue], + ) -> RunRequest: ... + + def to_mapping(self) -> dict[str, JSONValue]: ... +``` + +At most one input source is accepted; none means empty text. File inputs apply +only to `run()`. Request IDs default automatically, context is caller-owned, and +unknown fields are preserved. Complete requests reject separate request fields. +There is no `from_text()` or `input_text` alias. + +Merge precedence is: + +```text +base config < ordered profiles < service < session < invocation +``` + +Objects merge recursively; later scalars, arrays, and `null` replace earlier +values. Lists are not concatenated. Runtime changes are capability-gated. + +## Oneshot Runs + +`run()` resolves, plans, creates, invokes, collects, and destroys one runtime. +Cleanup failure raises `FabricRuntimeError` even after a successful invocation. + +## Sessions + +A `Session` owns one runtime and orders turns unless concurrency is declared. + +```python +class SessionInfo: + session_id: str + runtime_id: str + agent_name: str + profiles: Sequence[str] + harness: str + adapter_id: str + adapter_kind: str + status: Literal["active", "stopped", "failed"] + capabilities: RuntimeCapabilities + +class Session: + session_id: str + runtime_id: str + info: SessionInfo + + async def invoke( + self, + *, + input: JSONValue = None, + request: RunRequest | Mapping[str, JSONValue] | None = None, + request_id: str | None = None, + context: Mapping[str, JSONValue] | None = None, + overrides: Mapping[str, JSONValue] | None = None, + ) -> RunResult: ... + + async def stream( + self, + *, + input: JSONValue = None, + request: RunRequest | Mapping[str, JSONValue] | None = None, + request_id: str | None = None, + context: Mapping[str, JSONValue] | None = None, + overrides: Mapping[str, JSONValue] | None = None, + ) -> AsyncIterator[FabricEvent | RunResult]: ... + + async def update(self, update: RuntimeUpdate) -> RuntimeUpdateResult: ... + async def cancel(self) -> None: ... + async def stop(self) -> None: ... +``` + +- `Session.info` copies plan and runtime identity; it never derives one identity + field from another. +- `cancel()` targets the current invocation, leaves a supported runtime active, + and raises `FabricCapabilityError` when unsupported. +- `stop()` rejects active work and destroys an idle runtime exactly once. + Invoke, cancel, and stop transitions are serialized. + +## Services + +Service mode reuses one runtime. `RuntimeService` owns it; `ServiceSession` owns +only logical state. Callers retain serving, authentication, tenancy, persistence, +and scheduling. + +```python +class ServiceInfo: + service_id: str + runtime_id: str + agent_name: str + profiles: Sequence[str] + harness: str + adapter_id: str + adapter_kind: str + status: Literal["active", "stopped", "failed"] + capabilities: RuntimeCapabilities + +class ServiceSessionInfo: + service_id: str + session_id: str + runtime_id: str + status: Literal["active", "closed", "failed"] + +class ServiceSession: + service_id: str + session_id: str + info: ServiceSessionInfo + + async def invoke(...) -> RunResult: ... + async def stream(...) -> AsyncIterator[FabricEvent | RunResult]: ... + async def update(self, update: RuntimeUpdate) -> RuntimeUpdateResult: ... + async def cancel(self) -> None: ... + async def close(self) -> None: ... + +class RuntimeService: + service_id: str + runtime_id: str + info: ServiceInfo + + async def create_session( + self, + *, + session_id: str | None = None, + context: Mapping[str, JSONValue] | None = None, + overrides: Mapping[str, JSONValue] | None = None, + ) -> ServiceSession: ... + + async def get_session(self, session_id: str) -> ServiceSession: ... + async def invoke(...) -> RunResult: ... + async def stream(...) -> AsyncIterator[FabricEvent | RunResult]: ... + async def cancel(self, request_id: str) -> None: ... + async def update(self, update: RuntimeUpdate) -> RuntimeUpdateResult: ... + async def close_session(self, session_id: str) -> None: ... + async def stop(self) -> None: ... +``` + +Abbreviated invocation methods match `Session`. `ServiceSession.close()` releases +logical state; `RuntimeService.stop()` closes idle sessions and the runtime. +Direct service calls are stateless. IDs are correlation, not authorization. + +## Streaming and Updates + +`stream()` yields events and one terminal result. Adapters may buffer, so callers +must not assume immediate event delivery. Event kinds and metadata are additive. + +```python +class RuntimeUpdate: + overrides: Mapping[str, JSONValue] + metadata: Mapping[str, JSONValue] + +class RuntimeUpdateResult: + status: Literal["applied", "partially_applied", "rejected"] + applied: Mapping[str, JSONValue] + rejected: Mapping[str, JSONValue] + reason: str | None +``` + +The target determines update scope. Unsupported updates raise +`FabricCapabilityError`; supported updates report applied and rejected fields. + +## Results and Identity + +```python +class ErrorInfo: + stage: str + code: str + message: str + retryable: bool + metadata: Mapping[str, JSONValue] + +class ArtifactRef: + name: str + kind: str + path: Path + media_type: str | None + metadata: Mapping[str, JSONValue] + +class ArtifactManifest: + root: Path | None + artifacts: Sequence[ArtifactRef] + +class TelemetryRef: + provider: str + kind: str + uri: str | None + trace_id: str | None + metadata: Mapping[str, JSONValue] + +class FabricEvent: + event_id: str + timestamp_millis: int + kind: str + message: str + metadata: Mapping[str, JSONValue] + +class RunResult: + agent_name: str + profiles: Sequence[str] + harness: str + adapter_kind: str + adapter_id: str + runtime_id: str + invocation_id: str + request_id: str + status: Literal["succeeded", "failed", "cancelled"] + output: JSONValue + error: ErrorInfo | None + artifacts: ArtifactManifest + telemetry: Sequence[TelemetryRef] + events: Sequence[FabricEvent] + metadata: Mapping[str, JSONValue] + extra_fields: Mapping[str, JSONValue] +``` + +`profiles` is the full ordered stack; no singular field exists. Harness, adapter, +and runtime identities stay distinct. Normalized harness failure returns a +failed result; lifecycle failure raises a typed exception. + +## Errors + +```python +class FabricError(RuntimeError): + stage: str | None + code: str | None + retryable: bool + details: Mapping[str, JSONValue] + +class FabricConfigError(FabricError): ... +class FabricRuntimeError(FabricError): ... +class FabricStateError(FabricRuntimeError): ... +class FabricCapabilityError(FabricRuntimeError): ... +class FabricNativeUnavailableError(FabricRuntimeError): ... +``` + +Invalid input, unsupported operations, bad handle state, and lifecycle failure +map to the four specific errors above. Native exceptions never leak. Python task +cancellation remains `asyncio.CancelledError` with deterministic cleanup. + +## Compatibility + +- Unknown fields survive Python, native, adapter, and serialization boundaries. +- New optional fields and event kinds are additive. +- New required fields require a schema-version change. +- Capabilities declare support for session, service, streaming, updates, + cancellation, and concurrency. +- Public symbols and signatures are covered by static type and API contract + tests. +- Aliases are added only for migration from an actually released API. + +## Non-Goals + +The SDK does not own external server lifecycle, authentication, tenancy policy, +durable job persistence, UI state, evaluation scoring, or caller-specific +orchestration. diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py index 8fcbd95e9..e9987db2d 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -3,18 +3,73 @@ """Python SDK surface for NeMo Fabric.""" -from nemo_fabric.client import ( - FabricCliError, - FabricClient, +from nemo_fabric.client import FabricClient +from nemo_fabric.errors import ( + FabricCapabilityError, + FabricConfigError, + FabricError, FabricNativeUnavailableError, - Session, - SessionStatus, + FabricRuntimeError, + FabricStateError, +) +from nemo_fabric.session import Session, SessionStatus +from nemo_fabric.types import ( + AdapterInfo, + ArtifactManifest, + ArtifactRef, + DoctorCheck, + DoctorReport, + EffectiveConfig, + EnvironmentConfig, + ErrorInfo, + FabricConfig, + FabricEvent, + FabricProfileConfig, + HarnessConfig, + MetadataConfig, + RunPlan, + RunRequest, + RunResult, + RuntimeCapabilities, + RuntimeHandle, + RuntimeConfig, + RuntimeUpdate, + RuntimeUpdateResult, + SessionInfo, + TelemetryRef, ) __all__ = [ - "FabricCliError", + "AdapterInfo", + "ArtifactManifest", + "ArtifactRef", + "DoctorCheck", + "DoctorReport", + "EffectiveConfig", + "EnvironmentConfig", + "ErrorInfo", + "FabricConfig", + "FabricCapabilityError", "FabricClient", + "FabricConfigError", + "FabricError", + "FabricEvent", + "FabricProfileConfig", + "HarnessConfig", + "MetadataConfig", "FabricNativeUnavailableError", + "FabricRuntimeError", + "FabricStateError", + "RunPlan", + "RunRequest", + "RunResult", + "RuntimeCapabilities", + "RuntimeHandle", + "RuntimeConfig", + "RuntimeUpdate", + "RuntimeUpdateResult", "Session", + "SessionInfo", "SessionStatus", + "TelemetryRef", ] diff --git a/python/src/nemo_fabric/_config_sources.py b/python/src/nemo_fabric/_config_sources.py new file mode 100644 index 000000000..ead0a8462 --- /dev/null +++ b/python/src/nemo_fabric/_config_sources.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Agent source normalization for the Fabric Python SDK.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping, Sequence +from typing import Any + +from nemo_fabric.errors import FabricConfigError +from nemo_fabric.types import FabricConfig, FabricProfileConfig + +PathSource = str | os.PathLike[str] +AgentSource = PathSource | FabricConfig +ProfileSource = str | FabricProfileConfig +PathProfiles = str | Sequence[str] + + +def is_config_source(value: Any) -> bool: + return isinstance(value, FabricConfig) + + +def path_arg(value: Any) -> str: + if isinstance(value, (str, os.PathLike)): + return os.fspath(value) + if isinstance(value, Mapping): + raise FabricConfigError( + "agent mappings are not accepted directly; " + "use FabricConfig.from_mapping(...) first" + ) + raise FabricConfigError("agent must be a path-like source or FabricConfig") + + +def path_profiles(profiles: PathProfiles | None) -> list[str]: + if profiles is None: + return [] + if isinstance(profiles, str): + values = [profiles] + elif isinstance(profiles, bytes): + raise FabricConfigError("profiles must be profile names, not bytes") + elif isinstance(profiles, Mapping): + raise FabricConfigError("profiles must be profile names, not a mapping") + else: + values = list(profiles) + if not all(isinstance(profile, str) and profile for profile in values): + raise FabricConfigError("path profiles must contain only non-empty strings") + return values + + +def config_profiles( + profiles: Sequence[FabricProfileConfig] | None, +) -> list[FabricProfileConfig]: + if profiles is None: + return [] + if isinstance(profiles, (str, bytes)): + raise FabricConfigError( + "FabricConfig profiles must contain FabricProfileConfig values" + ) + values = list(profiles) + if not all(isinstance(profile, FabricProfileConfig) for profile in values): + raise FabricConfigError( + "FabricConfig profiles must contain FabricProfileConfig values; " + "use FabricProfileConfig.from_mapping(...) for mappings" + ) + return values + + +def validate_base_dir(agent: AgentSource, base_dir: PathSource | None) -> str | None: + if not isinstance(agent, FabricConfig): + if base_dir is not None: + raise FabricConfigError("base_dir is only valid with a FabricConfig source") + return None + return None if base_dir is None else os.fspath(base_dir) + + +def config_json(config: FabricConfig) -> str: + if not isinstance(config, FabricConfig): + raise FabricConfigError("config must be a FabricConfig") + return json.dumps(config.to_mapping()) + + +def profiles_json(profiles: Sequence[FabricProfileConfig]) -> str | None: + if not profiles: + return None + return json.dumps([profile.to_mapping() for profile in profiles]) diff --git a/python/src/nemo_fabric/_native.pyi b/python/src/nemo_fabric/_native.pyi index bd0252af8..1f7ad6b85 100644 --- a/python/src/nemo_fabric/_native.pyi +++ b/python/src/nemo_fabric/_native.pyi @@ -4,6 +4,11 @@ def version() -> str: ... def validate(path: str) -> str: ... def inspect(path: str, profile: str | list[str] | None = None) -> str: ... +def resolve_config( + config_json: str, + profiles_json: str | None = None, + base_dir: str | None = None, +) -> str: ... def plan(path: str, profile: str | list[str] | None = None) -> str: ... def plan_config( config_json: str, diff --git a/python/src/nemo_fabric/client.py b/python/src/nemo_fabric/client.py index ce12f1627..40164b5df 100644 --- a/python/src/nemo_fabric/client.py +++ b/python/src/nemo_fabric/client.py @@ -1,29 +1,52 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Python client for NeMo Fabric. - -The SDK uses the native Rust binding when the package is installed with -maturin. It falls back to the Fabric CLI when the native extension is not -available or when a CLI command is configured explicitly. -""" +"""Native Python client for NeMo Fabric.""" from __future__ import annotations -import asyncio -import concurrent.futures import importlib import json -import os -import shlex -import subprocess -import uuid -from collections.abc import AsyncIterator, Mapping -from copy import deepcopy -from dataclasses import dataclass -from enum import Enum +from collections.abc import Mapping, Sequence from pathlib import Path -from typing import Any, Iterable, Sequence +from typing import Any, overload + +from nemo_fabric._config_sources import ( + AgentSource, + PathProfiles, + PathSource, + config_json, + config_profiles, + is_config_source, + path_arg, + path_profiles, + profiles_json, + validate_base_dir, +) +from nemo_fabric.errors import ( + FabricCapabilityError, + FabricConfigError, + FabricError, + FabricNativeUnavailableError, + FabricRuntimeError, +) +from nemo_fabric.session import ( + Session, + _call_blocking, + _json_mapping, + _require_session_runtime, + _run_native_lifecycle, + _run_request_payload, +) +from nemo_fabric.types import ( + DoctorReport, + EffectiveConfig, + FabricConfig, + FabricProfileConfig, + RunPlan, + RunRequest, + RunResult, +) try: _native = importlib.import_module("nemo_fabric._native") @@ -31,27 +54,8 @@ _native = None -class FabricCliError(RuntimeError): - """Raised when the Fabric CLI exits unsuccessfully.""" - - def __init__(self, command: Sequence[str], returncode: int, stdout: str, stderr: str) -> None: - super().__init__(f"Fabric CLI failed with exit code {returncode}: {' '.join(command)}") - self.command = tuple(command) - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr - - -class FabricNativeUnavailableError(RuntimeError): - """Raised when an SDK method requires the native extension.""" - - -@dataclass(frozen=True) class FabricClient: - """Python entrypoint for Fabric config, planning, diagnostics, and runs.""" - - command: tuple[str, ...] | None = None - cwd: Path | None = None + """Entrypoint for Fabric resolution, planning, diagnostics, and execution.""" async def __aenter__(self) -> "FabricClient": return self @@ -59,703 +63,330 @@ async def __aenter__(self) -> "FabricClient": async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: return None - def validate(self, path: str | Path) -> str: - """Validate a Fabric agent directory or config file.""" + @overload + def resolve( + self, + agent: PathSource, + *, + profiles: PathProfiles | None = None, + base_dir: None = None, + ) -> EffectiveConfig: ... - native = self._native_module() - if native is not None: - return native.validate(str(path)) - return self._call_text(["validate", str(path)]) + @overload + def resolve( + self, + agent: FabricConfig, + *, + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> EffectiveConfig: ... - def inspect( - self, path: str | Path, *, profile: str | Sequence[str] | None = None - ) -> dict[str, Any]: - """Resolve and return the effective Fabric config.""" + def resolve( + self, + agent: AgentSource, + *, + profiles: PathProfiles | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> EffectiveConfig: + """Resolve config and ordered profiles without planning execution.""" - native = self._native_module() - native_profile = _native_profile_arg(profile) - if native is not None: - return json.loads(native.inspect(str(path), native_profile)) - args = ["inspect", str(path)] - args.extend(_profile_args(profile)) - return self._call_json(args) + native = self._require_native_module("resolve") + try: + if is_config_source(agent): + typed_profiles = config_profiles(profiles) # type: ignore[arg-type] + raw = native.resolve_config( + config_json(agent), + profiles_json(typed_profiles), + validate_base_dir(agent, base_dir), + ) + else: + validate_base_dir(agent, base_dir) + raw = native.inspect( + path_arg(agent), path_profiles(profiles) # type: ignore[arg-type] + ) + return EffectiveConfig.from_mapping(json.loads(raw)) + except FabricError: + raise + except Exception as error: + raise FabricConfigError(str(error)) from error + @overload def plan( - self, path: str | Path, *, profile: str | Sequence[str] | None = None - ) -> dict[str, Any]: - """Resolve an agent/profile into a run plan.""" + self, + agent: PathSource, + *, + profiles: PathProfiles | None = None, + base_dir: None = None, + ) -> RunPlan: ... - native = self._native_module() - native_profile = _native_profile_arg(profile) - if native is not None: - return json.loads(native.plan(str(path), native_profile)) - args = ["plan", str(path)] - args.extend(_profile_args(profile)) - return self._call_json(args) - - def plan_config( + @overload + def plan( self, - config: Mapping[str, Any] | Any, + agent: FabricConfig, *, - profile_configs: Sequence[Mapping[str, Any] | Any] | None = None, - base_dir: str | Path | None = None, - ) -> dict[str, Any]: - """Resolve an in-memory typed config into a run plan.""" - - native = self._require_native_module("plan_config") - return json.loads( - native.plan_config( - _config_json(config), - _profiles_json(profile_configs), - None if base_dir is None else str(base_dir), - ) - ) + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> RunPlan: ... + + def plan( + self, + agent: AgentSource, + *, + profiles: PathProfiles | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> RunPlan: + """Resolve a source into an immutable execution plan.""" + + native = self._require_native_module("plan") + try: + if is_config_source(agent): + typed_profiles = config_profiles(profiles) # type: ignore[arg-type] + raw = native.plan_config( + config_json(agent), + profiles_json(typed_profiles), + validate_base_dir(agent, base_dir), + ) + else: + validate_base_dir(agent, base_dir) + raw = native.plan( + path_arg(agent), path_profiles(profiles) # type: ignore[arg-type] + ) + return RunPlan.from_mapping(json.loads(raw)) + except FabricError: + raise + except Exception as error: + raise FabricConfigError(str(error)) from error + @overload async def doctor( - self, path: str | Path, *, profile: str | Sequence[str] | None = None - ) -> dict[str, Any]: - """Diagnose a run plan without installing or running the harness.""" + self, + agent: PathSource, + *, + profiles: PathProfiles | None = None, + base_dir: None = None, + ) -> DoctorReport: ... - native = self._native_module() - native_profile = _native_profile_arg(profile) - if native is not None: - return await _call_blocking( - lambda: json.loads(native.doctor(str(path), native_profile)) - ) - args = ["doctor", str(path)] - args.extend(_profile_args(profile)) - return await self._call_json_async(args) + @overload + async def doctor( + self, + agent: FabricConfig, + *, + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> DoctorReport: ... - async def doctor_config( + async def doctor( self, - config: Mapping[str, Any] | Any, + agent: AgentSource, *, - profile_configs: Sequence[Mapping[str, Any] | Any] | None = None, - base_dir: str | Path | None = None, - ) -> dict[str, Any]: - """Diagnose an in-memory typed config without running the harness.""" - - native = self._require_native_module("doctor_config") - return await _call_blocking( - lambda: json.loads( - native.doctor_config( - _config_json(config), - _profiles_json(profile_configs), - None if base_dir is None else str(base_dir), + profiles: PathProfiles | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + ) -> DoctorReport: + """Diagnose a resolved plan without starting a runtime.""" + + native = self._require_native_module("doctor") + + def diagnose() -> DoctorReport: + if is_config_source(agent): + typed_profiles = config_profiles(profiles) # type: ignore[arg-type] + raw = native.doctor_config( + config_json(agent), + profiles_json(typed_profiles), + validate_base_dir(agent, base_dir), ) - ) - ) + else: + validate_base_dir(agent, base_dir) + raw = native.doctor( + path_arg(agent), path_profiles(profiles) # type: ignore[arg-type] + ) + return DoctorReport.from_mapping(json.loads(raw)) + try: + return await _call_blocking(diagnose) + except FabricError: + raise + except Exception as error: + raise FabricConfigError(str(error)) from error + + @overload async def run( self, - path: str | Path, + agent: PathSource, *, - profile: str | Sequence[str] | None = None, - input_text: str = "", + profiles: PathProfiles | None = None, + base_dir: None = None, + input: Any = None, input_file: str | Path | None = None, - request: dict[str, Any] | None = None, + request: RunRequest | Mapping[str, Any] | None = None, request_file: str | Path | None = None, - ) -> dict[str, Any]: - """Run an agent/profile through the selected Fabric adapter.""" + request_id: str | None = None, + context: Mapping[str, Any] | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> RunResult: ... - native = self._native_module() - native_profile = _native_profile_arg(profile) - if native is not None: - request_payload = _run_request_payload( - input_text=input_text, - input_file=input_file, - request=request, - request_file=request_file, - ) - plan = json.loads(native.plan(str(path), native_profile)) - return await _run_native_lifecycle(native, plan, request_payload) - args = ["run", str(path)] - args.extend(_profile_args(profile)) - if request_file is not None: - args.extend(["--request-file", str(request_file)]) - elif request is not None: - args.extend(["--request-json", json.dumps(request)]) - elif input_file is not None: - args.extend(["--input-file", str(input_file)]) - else: - args.extend(["--input", input_text]) - return await self._call_json_async(args) - - async def run_config( + @overload + async def run( self, - config: Mapping[str, Any] | Any, + agent: FabricConfig, *, - profile_configs: Sequence[Mapping[str, Any] | Any] | None = None, - base_dir: str | Path | None = None, - input_text: str = "", + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + input: Any = None, input_file: str | Path | None = None, - request: dict[str, Any] | None = None, + request: RunRequest | Mapping[str, Any] | None = None, request_file: str | Path | None = None, - ) -> dict[str, Any]: - """Run an in-memory typed config through the selected Fabric adapter.""" + request_id: str | None = None, + context: Mapping[str, Any] | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> RunResult: ... - native = self._require_native_module("run_config") + async def run( + self, + agent: AgentSource, + *, + profiles: PathProfiles | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + input: Any = None, + input_file: str | Path | None = None, + request: RunRequest | Mapping[str, Any] | None = None, + request_file: str | Path | None = None, + request_id: str | None = None, + context: Mapping[str, Any] | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> RunResult: + """Execute one complete runtime lifecycle.""" + + plan = await _call_blocking( + lambda: self.plan( # type: ignore[arg-type] + agent, profiles=profiles, base_dir=base_dir + ) + ) request_payload = _run_request_payload( - input_text=input_text, + input=input, input_file=input_file, request=request, request_file=request_file, + request_id=request_id, + context=context, + overrides=overrides, ) - plan = json.loads( - native.plan_config( - _config_json(config), - _profiles_json(profile_configs), - None if base_dir is None else str(base_dir), - ) + native = self._require_native_module("run") + return RunResult.from_mapping( + await _run_native_lifecycle(native, plan.to_mapping(), request_payload) ) - return await _run_native_lifecycle(native, plan, request_payload) - async def start( + @overload + async def start_session( self, - path: str | Path, + agent: PathSource, *, - profile: str | Sequence[str] | None = None, - overrides: dict[str, Any] | None = None, + profiles: PathProfiles | None = None, + base_dir: None = None, session_id: str | None = None, - ) -> "Session": - """Open a multi-turn session over an agent/profile runtime. - - Args: - path: Agent package directory or config file to resolve. - profile: Profile name, or several applied in order, layered onto the - base config. - overrides: Config overrides applied to every turn in the session; a - turn's own ``overrides`` merge over these. - session_id: Optional caller-provided harness conversation id. - Defaults to the Fabric runtime id. - - Returns: - An active :class:`Session` bound to the resolved plan. - - Raises: - FabricNativeUnavailableError: The native extension is unavailable - (sessions are not supported over the CLI fallback). - """ - - native = self._require_native_module("start") - plan = self.plan(path, profile=profile) - _require_session_runtime(plan, "start") - runtime = await _call_blocking( - lambda: json.loads(native.start_runtime(json.dumps(plan))) - ) - return Session( - client=self, - plan=plan, - runtime=runtime, - overrides=overrides, - session_id=session_id, - ) + overrides: Mapping[str, Any] | None = None, + ) -> Session: ... - async def start_config( + @overload + async def start_session( self, - config: Mapping[str, Any] | Any, + agent: FabricConfig, *, - profile_configs: Sequence[Mapping[str, Any] | Any] | None = None, - base_dir: str | Path | None = None, - overrides: dict[str, Any] | None = None, + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, session_id: str | None = None, - ) -> "Session": - """Open a multi-turn session over an in-memory typed config. - - Args: - config: Typed Fabric config as a mapping or a Pydantic-like object - (``model_dump()``/``dict()``); no agent directory required. - profile_configs: Profile configs layered onto the base config, in order. - base_dir: Resolution root for relative paths and package-local - adapters. ``None`` resolves against the process working directory. - overrides: Config overrides applied to every turn; a turn's own - ``overrides`` merge over these. - session_id: Optional caller-provided harness conversation id. - Defaults to the Fabric runtime id. - - Returns: - An active :class:`Session` bound to the resolved plan. - - Raises: - FabricNativeUnavailableError: The native extension is unavailable. - """ - - native = self._require_native_module("start_config") - plan = self.plan_config( - config, profile_configs=profile_configs, base_dir=base_dir - ) - _require_session_runtime(plan, "start_config") - runtime = await _call_blocking( - lambda: json.loads(native.start_runtime(json.dumps(plan))) + overrides: Mapping[str, Any] | None = None, + ) -> Session: ... + + async def start_session( + self, + agent: AgentSource, + *, + profiles: PathProfiles | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + session_id: str | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> Session: + """Create a session runtime from a path-backed or typed source.""" + + session_overrides = _json_mapping(overrides, "session overrides") + plan = await _call_blocking( + lambda: self.plan( # type: ignore[arg-type] + agent, profiles=profiles, base_dir=base_dir + ) ) + _require_session_runtime(plan, "start_session") + native = self._require_native_module("start_session") + try: + runtime = await _call_blocking( + lambda: json.loads(native.start_runtime(json.dumps(plan.to_mapping()))) + ) + except FabricError: + raise + except Exception as error: + raise FabricRuntimeError(str(error), stage="start") from error return Session( client=self, plan=plan, runtime=runtime, - overrides=overrides, + overrides=session_overrides, session_id=session_id, ) - def _command(self) -> tuple[str, ...]: - if self.command is not None: - return self.command - env_command = os.environ.get("FABRIC_CLI") - if env_command: - return tuple(shlex.split(env_command)) - return ("fabric",) - - def _call_text(self, args: Iterable[str]) -> str: - completed = self._run(args) - return completed.stdout.strip() - - def _call_json(self, args: Iterable[str]) -> dict[str, Any]: - completed = self._run(args) - return json.loads(completed.stdout) - - async def _call_json_async(self, args: Iterable[str]) -> dict[str, Any]: - completed = await self._run_async(args) - return json.loads(completed.stdout) - - def _run(self, args: Iterable[str]) -> subprocess.CompletedProcess[str]: - command = [*self._command(), *args] - completed = subprocess.run( - command, - cwd=self.cwd, - text=True, - capture_output=True, - check=False, - ) - if completed.returncode != 0: - raise FabricCliError(command, completed.returncode, completed.stdout, completed.stderr) - return completed - - async def _run_async(self, args: Iterable[str]) -> subprocess.CompletedProcess[str]: - command = [*self._command(), *args] - process = await asyncio.create_subprocess_exec( - *command, - cwd=None if self.cwd is None else str(self.cwd), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout_bytes, stderr_bytes = await process.communicate() - stdout = stdout_bytes.decode() - stderr = stderr_bytes.decode() - if process.returncode != 0: - raise FabricCliError(command, process.returncode or 1, stdout, stderr) - return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) - - def _native_module(self) -> Any | None: - if self.command is not None: - return None - if os.environ.get("FABRIC_CLI"): - return None - return _native - - def _require_native_module(self, method: str) -> Any: - native = self._native_module() - if native is None: - raise FabricNativeUnavailableError( - f"{method} requires the nemo_fabric native extension; " - "the CLI fallback only supports file-based agent configs" - ) - return native - - -class SessionStatus(str, Enum): - """Lifecycle state of a :class:`Session`.""" - - ACTIVE = "active" - STOPPED = "stopped" - CANCELLED = "cancelled" - - -class Session: - """A multi-turn session over a Fabric runtime. - - Created by :meth:`FabricClient.start` / :meth:`FabricClient.start_config`. - Each :meth:`invoke` runs one turn through the same core ``RuntimeHandle``. - Harness state is owned by the selected adapter/runtime, not replayed from a - Python-side transcript. - """ - - def __init__( + @overload + async def start_service( self, + agent: PathSource, *, - client: "FabricClient", - plan: dict[str, Any], - runtime: dict[str, Any], - overrides: dict[str, Any] | None = None, - session_id: str | None = None, - ) -> None: - _require_session_runtime(plan, "Session") - self._client = client - self._plan = plan - self._runtime = runtime - self._overrides = overrides - self._session_id = session_id - self._messages: list[Any] = [] - self._invocations: list[dict[str, Any]] = [] - self._status = SessionStatus.ACTIVE - self._current_task: asyncio.Task[Any] | None = None - self._closing = False - - @property - def status(self) -> SessionStatus: - return self._status - - @property - def messages(self) -> list[Any]: - """Read-only deep copy of the accumulated transcript.""" - - return deepcopy(self._messages) - - @property - def invocations(self) -> list[dict[str, Any]]: - """Per-turn ``{request_id, runtime_id, invocation_id}`` correlation data.""" - - return list(self._invocations) - - @property - def runtime(self) -> dict[str, Any]: - """Read-only deep copy of the active ``RuntimeHandle``.""" - - return deepcopy(self._runtime) - - @property - def runtime_id(self) -> str: - """Canonical Fabric runtime id for this session.""" - - return str(self._runtime["runtime_id"]) - - @property - def session_id(self) -> str: - """Harness conversation id used for this session.""" - - return str(self._session_id or self.runtime_id) - - @property - def info(self) -> dict[str, Any]: - """Summary handle for the active Fabric runtime and selected adapter.""" - - return { - "runtime_id": self._runtime.get("runtime_id"), - "agent_name": self._plan.get("agent_name"), - "profile": self._plan.get("profile"), - "harness_type": _harness_type(self._plan), - "adapter_kind": _adapter_kind(self._plan), - } - - async def invoke( + profiles: PathProfiles | None = None, + base_dir: None = None, + service_id: str | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> Any: ... + + @overload + async def start_service( self, - input_text: str | None = None, + agent: FabricConfig, *, - request: dict[str, Any] | None = None, - overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Run one turn on the session runtime. - - Args: - input_text: Text input for the turn. Ignored when ``request`` is given. - request: A full ``RunRequest`` mapping for the turn, as an alternative - to ``input_text``. - overrides: Per-turn config overrides, merged over the session-level - overrides passed to :meth:`FabricClient.start`. - - Returns: - The turn's normalized ``RunResult`` mapping. ``messages`` is updated - only when the adapter returns a ``messages`` list in its output. - - Raises: - RuntimeError: The session is not active (already stopped or cancelled). - """ - - if self._status is not SessionStatus.ACTIVE: - raise RuntimeError(f"cannot invoke a {self._status.value} session") - if self._closing: - raise RuntimeError("cannot invoke while session shutdown is in progress") - if self._current_task is not None: - raise RuntimeError( - "session is already running a turn; turns are ordered (one at a time)" - ) - # Claim the turn before any await so callers cannot concurrently invoke - # the same runtime handle. - self._current_task = asyncio.current_task() - try: - request_payload = _run_request_payload( - input_text=input_text or "", - input_file=None, - request=request, - request_file=None, - ) - request_payload["context"]["session_id"] = self.session_id - # Merge overrides as session < request < per-turn; request-level - # overrides must not bypass the documented session/turn merge. - merged_overrides = _merge_overrides(self._overrides, request_payload.get("overrides")) - merged_overrides = _merge_overrides(merged_overrides, overrides) - if merged_overrides is not None: - request_payload["overrides"] = merged_overrides - native = self._client._require_native_module("invoke") - result = await _call_blocking( - lambda: json.loads( - native.invoke_runtime( - json.dumps(self._plan), - json.dumps(self._runtime), - json.dumps(request_payload), - ) - ) - ) - self._absorb(result) - return result - finally: - self._current_task = None + profiles: Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + service_id: str | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> Any: ... - async def stream( + async def start_service( self, - input_text: str | None = None, + agent: AgentSource, *, - request: dict[str, Any] | None = None, - overrides: dict[str, Any] | None = None, - ) -> AsyncIterator[dict[str, Any]]: - """Run one turn and yield its events, then the final ``RunResult``. - - Buffered: the turn runs to completion via :meth:`invoke`, then the - normalized ``events`` are yielded in order, followed by the terminal - ``RunResult`` (the last item). The async-iterator shape is - forward-compatible with live token streaming if a harness exposes one. - - Args: - input_text: Text input for the turn. Ignored when ``request`` is given. - request: A full ``RunRequest`` mapping for the turn. - overrides: Per-turn config overrides, merged over the session-level - overrides. - - Yields: - Each ``fabric-event`` mapping for the turn, in order, then the final - ``RunResult`` mapping as the terminal item. - - Raises: - RuntimeError: The session is not active (already stopped or cancelled). - """ - - result = await self.invoke(input_text, request=request, overrides=overrides) - for event in result.get("events") or []: - yield event - yield result - - async def cancel(self) -> None: - """Cancel the in-flight turn and close the session. Idempotent. - - Cooperative: cancels the awaiting :meth:`invoke` / :meth:`stream` - coroutine and marks the session ``CANCELLED``. Already-dispatched - blocking native calls may run to completion and their result is discarded. - """ - - if self._status is not SessionStatus.ACTIVE: - return - if self._closing: - raise RuntimeError("session shutdown is already in progress") - self._closing = True - task = self._current_task - if task is not None and not task.done() and task is not asyncio.current_task(): - task.cancel() - try: - await self._stop_runtime() - except Exception: - self._closing = False - raise - else: - self._status = SessionStatus.CANCELLED - self._closing = False - - async def stop(self) -> None: - """Finalize the session. Idempotent.""" - - if self._status is SessionStatus.ACTIVE: - task = self._current_task - if task is not None and not task.done() and task is not asyncio.current_task(): - raise RuntimeError("cannot stop while a turn is in flight; use cancel()") - if self._closing: - raise RuntimeError("session shutdown is already in progress") - self._closing = True - try: - await self._stop_runtime() - except Exception: - self._closing = False - raise - else: - self._status = SessionStatus.STOPPED - self._closing = False - - async def _stop_runtime(self) -> None: - native = self._client._require_native_module("stop") - await _call_blocking( - lambda: json.loads( - native.stop_runtime(json.dumps(self._plan), json.dumps(self._runtime)) + profiles: PathProfiles | Sequence[FabricProfileConfig] | None = None, + base_dir: PathSource | None = None, + service_id: str | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> Any: + """Reject service creation until the selected runtime declares support.""" + + _json_mapping(overrides, "service overrides") + plan = await _call_blocking( + lambda: self.plan( # type: ignore[arg-type] + agent, profiles=profiles, base_dir=base_dir ) ) - - def _absorb(self, result: Any) -> None: - """Record the turn's handles and advance the transcript from its ``RunResult``.""" - - if not isinstance(result, dict): - return - self._invocations.append( - { - "request_id": result.get("request_id"), - "runtime_id": result.get("runtime_id"), - "invocation_id": result.get("invocation_id"), - } + raise FabricCapabilityError( + "service mode is not implemented by this Fabric runtime", + stage="start", + code="service_not_supported", + details={"service": plan.capabilities.service, "service_id": service_id}, ) - output = result.get("output") - if not isinstance(output, dict): - return - messages = output.get("messages") - if isinstance(messages, list): - self._messages = deepcopy(messages) - - async def __aenter__(self) -> "Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: - await self.stop() - - -def _merge_overrides( - base: dict[str, Any] | None, extra: dict[str, Any] | None -) -> dict[str, Any] | None: - merged: dict[str, Any] = {} - if isinstance(base, dict): - merged.update(base) - if isinstance(extra, dict): - merged.update(extra) - return merged or None - - -def _profile_args(profile: str | Sequence[str] | None) -> list[str]: - if profile is None: - return [] - if isinstance(profile, str): - return ["--profile", profile] - args: list[str] = [] - for value in profile: - args.extend(["--profile", value]) - return args - - -def _native_profile_arg(profile: str | Sequence[str] | None) -> str | list[str] | None: - if profile is None or isinstance(profile, str): - return profile - profiles = list(profile) - if not profiles: - return None - return profiles - - -def _config_json(config: Mapping[str, Any] | Any) -> str: - return json.dumps(_json_compatible(config)) + def _native_module(self) -> Any | None: + return _native -def _profiles_json(profiles: Sequence[Mapping[str, Any] | Any] | None) -> str | None: - if profiles is None: - return None - return json.dumps([_json_compatible(profile) for profile in profiles]) - - -def _json_compatible(value: Mapping[str, Any] | Any) -> dict[str, Any]: - if hasattr(value, "model_dump"): - return value.model_dump(mode="json", exclude_none=True) - if hasattr(value, "dict"): - return value.dict(exclude_none=True) - if isinstance(value, Mapping): - return dict(value) - raise TypeError( - "config values must be mappings or Pydantic-like objects with model_dump()/dict()" - ) - - -def _run_request_payload( - *, - input_text: str, - input_file: str | Path | None, - request: dict[str, Any] | None, - request_file: str | Path | None, -) -> dict[str, Any]: - if request_file is not None: - with Path(request_file).open(encoding="utf-8") as stream: - payload = json.load(stream) - elif request is not None: - payload = json.loads(json.dumps(request)) - elif input_file is not None: - payload = {"input": Path(input_file).read_text(encoding="utf-8")} - else: - payload = {"input": input_text} - if not isinstance(payload, dict): - raise TypeError("request payload must be a JSON object") - payload.setdefault("request_id", f"request-{uuid.uuid4().hex}") - payload.setdefault("context", {}) - if not isinstance(payload["context"], dict): - raise TypeError("request context must be a JSON object") - return payload - - -async def _run_native_lifecycle( - native: Any, - plan: dict[str, Any], - request: dict[str, Any], -) -> dict[str, Any]: - def _run() -> dict[str, Any]: - plan_json = json.dumps(plan) - runtime = json.loads(native.start_runtime(plan_json)) - runtime_json = json.dumps(runtime) - result: dict[str, Any] | None = None - invoke_error: Exception | None = None - try: - try: - result = json.loads( - native.invoke_runtime(plan_json, runtime_json, json.dumps(request)) - ) - except Exception as error: - invoke_error = error - raise - return result - finally: - try: - stop_events = json.loads(native.stop_runtime(plan_json, runtime_json)) - except Exception: - if invoke_error is not None: - stop_events = [] - else: - raise - if isinstance(result, dict) and isinstance(stop_events, list): - result.setdefault("events", []).extend(stop_events) - - return await _call_blocking(_run) - - -async def _call_blocking(func: Any) -> Any: - loop = asyncio.get_running_loop() - with concurrent.futures.ThreadPoolExecutor( - max_workers=1, - thread_name_prefix="fabric-sdk", - ) as executor: - return await loop.run_in_executor(executor, func) - - -def _adapter_kind(plan: dict[str, Any]) -> str: - descriptor = ((plan.get("adapter_descriptor") or {}).get("descriptor") or {}) - return descriptor.get("adapter_kind", "process") - - -def _harness_type(plan: dict[str, Any]) -> str: - descriptor = ((plan.get("adapter_descriptor") or {}).get("descriptor") or {}) - return descriptor.get("adapter_id", "unknown") - - -def _require_session_runtime(plan: dict[str, Any], method: str) -> None: - runtime = ((plan.get("config") or {}).get("runtime") or {}) - mode = runtime.get("mode") - if mode != "session": - resolved = mode if isinstance(mode, str) else "unknown" - raise RuntimeError( - f"{method} requires runtime.mode=session; resolved runtime.mode={resolved}" - ) + def _require_native_module(self, method: str) -> Any: + native = self._native_module() + if native is None: + raise FabricNativeUnavailableError( + f"{method} requires the nemo_fabric native extension", + stage=method, + code="native_unavailable", + ) + return native diff --git a/python/src/nemo_fabric/errors.py b/python/src/nemo_fabric/errors.py new file mode 100644 index 000000000..67dd60ab0 --- /dev/null +++ b/python/src/nemo_fabric/errors.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public exception hierarchy for the NeMo Fabric Python SDK.""" + +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from typing import Any + + +class FabricError(RuntimeError): + """Base class for SDK-level Fabric errors.""" + + def __init__( + self, + message: str, + *, + stage: str | None = None, + code: str | None = None, + retryable: bool = False, + details: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.stage = stage + self.code = code + self.retryable = retryable + self.details = deepcopy(dict(details or {})) + + +class FabricConfigError(FabricError): + """Raised when SDK input or resolved config is invalid for the requested API.""" + + +class FabricRuntimeError(FabricError): + """Raised when a runtime lifecycle call fails.""" + + +class FabricStateError(FabricRuntimeError): + """Raised when a local SDK handle is used in an invalid lifecycle state.""" + + +class FabricCapabilityError(FabricRuntimeError): + """Raised when the resolved runtime does not support the requested operation.""" + + +class FabricNativeUnavailableError(FabricRuntimeError): + """Raised when an SDK method requires the native extension.""" diff --git a/python/src/nemo_fabric/integrations/harbor.py b/python/src/nemo_fabric/integrations/harbor.py index e7d47556f..5b0df624a 100644 --- a/python/src/nemo_fabric/integrations/harbor.py +++ b/python/src/nemo_fabric/integrations/harbor.py @@ -168,8 +168,8 @@ def populate_context_from_result(context: AgentContext, path: Path) -> None: "runtime_id": result.get("runtime_id"), "invocation_id": result.get("invocation_id"), "request_id": result.get("request_id"), - "profile": result.get("profile"), - "harness_type": result.get("harness_type"), + "profiles": result.get("profiles", []), + "harness": result.get("harness"), "adapter_id": result.get("adapter_id"), "artifacts": result.get("artifacts", {}), "telemetry": result.get("telemetry"), diff --git a/python/src/nemo_fabric/session.py b/python/src/nemo_fabric/session.py new file mode 100644 index 000000000..330093ea3 --- /dev/null +++ b/python/src/nemo_fabric/session.py @@ -0,0 +1,436 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Session lifecycle support for the Fabric Python SDK.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Mapping, Sequence +from copy import deepcopy +from enum import Enum +from pathlib import Path +from typing import Any + +from nemo_fabric.errors import ( + FabricCapabilityError, + FabricConfigError, + FabricError, + FabricRuntimeError, + FabricStateError, +) +from nemo_fabric.types import ( + FabricEvent, + RunPlan, + RunRequest, + RunResult, + RuntimeHandle, + RuntimeUpdate, + RuntimeUpdateResult, + SessionInfo, +) + + +class SessionStatus(str, Enum): + """Lifecycle state of a session runtime.""" + + ACTIVE = "active" + STOPPED = "stopped" + FAILED = "failed" + + +class Session: + """One ordered multi-turn conversation over a Fabric runtime.""" + + def __init__( + self, + *, + client: Any, + plan: RunPlan | Mapping[str, Any], + runtime: RuntimeHandle | Mapping[str, Any], + overrides: Mapping[str, Any] | None = None, + session_id: str | None = None, + ) -> None: + self._plan = plan if isinstance(plan, RunPlan) else RunPlan.from_mapping(plan) + _require_session_runtime(self._plan, "Session") + self._runtime = ( + runtime if isinstance(runtime, RuntimeHandle) else RuntimeHandle.from_mapping(runtime) + ) + self._client = client + self._overrides = _json_mapping(overrides, "session overrides") + self._session_id = session_id + self._messages: list[Any] = [] + self._invocations: list[dict[str, Any]] = [] + self._status = SessionStatus.ACTIVE + self._current_task: asyncio.Task[Any] | None = None + self._closing = False + + @property + def status(self) -> SessionStatus: + return self._status + + @property + def messages(self) -> list[Any]: + return deepcopy(self._messages) + + @property + def invocations(self) -> list[dict[str, Any]]: + return deepcopy(self._invocations) + + @property + def runtime(self) -> RuntimeHandle: + return RuntimeHandle.from_mapping(self._runtime.to_mapping()) + + @property + def runtime_id(self) -> str: + return self._runtime.runtime_id + + @property + def session_id(self) -> str: + return self._session_id or self.runtime_id + + @property + def info(self) -> SessionInfo: + return SessionInfo.from_mapping( + { + "session_id": self.session_id, + "runtime_id": self.runtime_id, + "agent_name": self._runtime.agent_name, + "profiles": self._plan.profiles, + "harness": self._runtime.harness, + "adapter_id": self._runtime.adapter_id, + "adapter_kind": self._runtime.adapter_kind, + "status": self._status.value, + "capabilities": self._plan.capabilities, + } + ) + + async def invoke( + self, + *, + input: Any = None, + request: RunRequest | Mapping[str, Any] | None = None, + request_id: str | None = None, + context: Mapping[str, Any] | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> RunResult: + """Run one turn; turns are serialized for non-concurrent runtimes.""" + + if self._status is not SessionStatus.ACTIVE: + raise FabricStateError(f"cannot invoke a {self._status.value} session") + if self._closing: + raise FabricStateError("cannot invoke while session shutdown is in progress") + if self._current_task is not None: + raise FabricStateError("session is already running a turn") + self._current_task = asyncio.current_task() + try: + payload = _run_request_payload( + input=input, + input_file=None, + request=request, + request_file=None, + request_id=request_id, + context=context, + overrides=overrides, + ) + payload["context"] = { + **payload.get("context", {}), + "session_id": self.session_id, + } + merged = _merge_overrides(self._overrides, payload.get("overrides")) + if merged: + payload["overrides"] = merged + else: + payload.pop("overrides", None) + try: + native = self._client._require_native_module("invoke") + result = await _call_blocking( + lambda: json.loads( + native.invoke_runtime( + json.dumps(self._plan.to_mapping()), + json.dumps(self._runtime.to_mapping()), + json.dumps(payload), + ) + ) + ) + typed_result = RunResult.from_mapping(result) + except FabricError: + self._status = SessionStatus.FAILED + raise + except Exception as error: + self._status = SessionStatus.FAILED + raise FabricRuntimeError(str(error), stage="invoke") from error + self._absorb(typed_result) + return typed_result + except FabricError: + raise + except Exception as error: + raise FabricRuntimeError(str(error), stage="invoke") from error + finally: + self._current_task = None + + async def stream( + self, + *, + input: Any = None, + request: RunRequest | Mapping[str, Any] | None = None, + request_id: str | None = None, + context: Mapping[str, Any] | None = None, + overrides: Mapping[str, Any] | None = None, + ) -> AsyncIterator[FabricEvent | RunResult]: + """Yield buffered events followed by one terminal result.""" + + result = await self.invoke( + input=input, + request=request, + request_id=request_id, + context=context, + overrides=overrides, + ) + for event in result.events: + yield event + yield result + + async def update(self, update: RuntimeUpdate) -> RuntimeUpdateResult: + """Apply a capability-gated runtime update.""" + + if not isinstance(update, RuntimeUpdate): + raise FabricConfigError("update must be a RuntimeUpdate") + if not self._plan.capabilities.updates: + raise FabricCapabilityError( + "runtime updates are not supported", + stage="update", + code="updates_not_supported", + ) + raise FabricCapabilityError( + "runtime update transport is not implemented", + stage="update", + code="updates_not_implemented", + ) + + async def cancel(self) -> None: + """Cancel the current invocation when the runtime declares support.""" + + if not self._plan.capabilities.cancellation: + raise FabricCapabilityError( + "runtime cancellation is not supported", + stage="cancel", + code="cancellation_not_supported", + ) + raise FabricCapabilityError( + "runtime cancellation transport is not implemented", + stage="cancel", + code="cancellation_not_implemented", + ) + + async def stop(self) -> None: + """Destroy an idle runtime exactly once.""" + + if self._status is SessionStatus.STOPPED: + return + if self._status is SessionStatus.FAILED: + raise FabricStateError("cannot stop a failed session") + if self._current_task is not None: + raise FabricStateError("cannot stop while a turn is in flight") + if self._closing: + raise FabricStateError("session shutdown is already in progress") + self._closing = True + try: + native = self._client._require_native_module("stop") + await _call_blocking( + lambda: json.loads( + native.stop_runtime( + json.dumps(self._plan.to_mapping()), + json.dumps(self._runtime.to_mapping()), + ) + ) + ) + except FabricError: + self._status = SessionStatus.FAILED + raise + except Exception as error: + self._status = SessionStatus.FAILED + raise FabricRuntimeError(str(error), stage="stop") from error + else: + self._status = SessionStatus.STOPPED + finally: + self._closing = False + + def _absorb(self, result: RunResult) -> None: + self._invocations.append( + { + "request_id": result.request_id, + "runtime_id": result.runtime_id, + "invocation_id": result.invocation_id, + } + ) + output = result.output + messages = output.get("messages") if isinstance(output, Mapping) else None + if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes)): + self._messages = deepcopy(list(messages)) + + async def __aenter__(self) -> "Session": + return self + + async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: + if self._status is not SessionStatus.FAILED: + await self.stop() + + +def _json_mapping(value: Mapping[str, Any] | None, name: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise FabricConfigError(f"{name} must be a JSON object") + pending: list[Any] = [value] + seen: set[int] = set() + while pending: + item = pending.pop() + if isinstance(item, (Mapping, list, tuple)): + identity = id(item) + if identity in seen: + continue + seen.add(identity) + if isinstance(item, Mapping): + if any(not isinstance(key, str) for key in item): + raise FabricConfigError(f"{name} keys must be strings") + pending.extend(item.values()) + elif isinstance(item, (list, tuple)): + pending.extend(item) + try: + return json.loads(json.dumps(dict(value), allow_nan=False)) + except (TypeError, ValueError) as error: + raise FabricConfigError(f"{name} must contain JSON-compatible values") from error + + +def _merge_overrides( + base: Mapping[str, Any] | None, + extra: Mapping[str, Any] | None, +) -> dict[str, Any]: + result = _json_mapping(base, "request overrides") + for key, value in _json_mapping(extra, "request overrides").items(): + current = result.get(key) + if isinstance(current, dict) and isinstance(value, dict): + result[key] = _merge_overrides(current, value) + else: + result[key] = value + return result + + +def _run_request_payload( + *, + input: Any, + input_file: str | Path | None, + request: RunRequest | Mapping[str, Any] | None, + request_file: str | Path | None, + request_id: str | None, + context: Mapping[str, Any] | None, + overrides: Mapping[str, Any] | None, +) -> dict[str, Any]: + primary_sources = [ + input is not None, + input_file is not None, + request is not None, + request_file is not None, + ] + if sum(primary_sources) > 1: + raise FabricConfigError( + "at most one input source is allowed: input, input_file, request, or request_file" + ) + separate_fields = request_id is not None or context is not None or overrides is not None + if (request is not None or request_file is not None) and separate_fields: + raise FabricConfigError( + "a complete request cannot be combined with separate request fields" + ) + if request_file is not None: + try: + raw = json.loads(Path(request_file).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise FabricConfigError(f"failed to read request file: {error}") from error + payload = RunRequest.from_mapping(raw).to_mapping() + elif request is not None: + payload = ( + request.to_mapping() + if isinstance(request, RunRequest) + else RunRequest.from_mapping(request).to_mapping() + ) + elif input_file is not None: + try: + file_input = Path(input_file).read_text(encoding="utf-8") + except OSError as error: + raise FabricConfigError(f"failed to read input file: {error}") from error + payload = RunRequest( + input=file_input, + request_id=request_id, + context=context, + overrides=overrides, + ).to_mapping() + else: + payload = RunRequest( + input=input, + request_id=request_id, + context=context, + overrides=overrides, + ).to_mapping() + return payload + + +async def _run_native_lifecycle( + native: Any, + plan: Mapping[str, Any], + request: Mapping[str, Any], +) -> dict[str, Any]: + def run() -> dict[str, Any]: + plan_json = json.dumps(dict(plan)) + runtime = json.loads(native.start_runtime(plan_json)) + runtime_json = json.dumps(runtime) + result: dict[str, Any] | None = None + invoke_error: Exception | None = None + try: + try: + result = json.loads( + native.invoke_runtime(plan_json, runtime_json, json.dumps(dict(request))) + ) + except Exception as error: + invoke_error = error + raise + return result + finally: + try: + stop_events = json.loads(native.stop_runtime(plan_json, runtime_json)) + except Exception: + if invoke_error is None: + raise + stop_events = [] + if result is not None and isinstance(stop_events, list): + result.setdefault("events", []).extend(stop_events) + + try: + return await _call_blocking(run) + except FabricError: + raise + except Exception as error: + raise FabricRuntimeError(str(error), stage="run") from error + + +async def _call_blocking(func: Any) -> Any: + task = asyncio.create_task(asyncio.to_thread(func)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + try: + await asyncio.shield(task) + except Exception: + pass + raise + + +def _require_session_runtime(plan: RunPlan | Mapping[str, Any], method: str) -> None: + typed_plan = plan if isinstance(plan, RunPlan) else RunPlan.from_mapping(plan) + if not typed_plan.capabilities.session: + raise FabricCapabilityError( + f"{method} requires session capability", + stage="start", + code="session_not_supported", + ) diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py new file mode 100644 index 000000000..5c3c9f75c --- /dev/null +++ b/python/src/nemo_fabric/types.py @@ -0,0 +1,979 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public data contracts for the NeMo Fabric Python SDK.""" + +from __future__ import annotations + +import math +import uuid +from collections.abc import Iterator, Mapping, Sequence +from copy import deepcopy +from pathlib import Path +from types import MappingProxyType +from typing import Any, TypeVar + +from nemo_fabric.errors import FabricConfigError + +JSONScalar = str | int | float | bool | None +JSONValue = JSONScalar | list["JSONValue"] | dict[str, "JSONValue"] + +_UNSET = object() +_T = TypeVar("_T") + + +def _plain(value: Any) -> Any: + if isinstance(value, Path): + return str(value) + if isinstance(value, _ConfigMapping): + return value.to_mapping() + if isinstance(value, FabricMapping): + return value.to_mapping() + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise FabricConfigError("JSON object keys must be strings") + result[key] = _plain(item) + return result + if isinstance(value, (list, tuple)): + return [_plain(item) for item in value] + if isinstance(value, float) and not math.isfinite(value): + raise FabricConfigError("JSON numbers must be finite") + if value is None or isinstance(value, (str, int, float, bool)): + return deepcopy(value) + raise FabricConfigError(f"value of type {type(value).__name__} is not JSON-compatible") + + +def _mapping(value: Any, name: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise FabricConfigError(f"{name} must be a JSON object") + return _plain(value) + + +def _required_text(value: Any, name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise FabricConfigError(f"{name} must be a non-empty string") + return value + + +def _required_profiles(data: Mapping[str, Any], owner: str) -> tuple[str, ...]: + if "profiles" not in data: + raise FabricConfigError(f"{owner} profiles is required") + profiles = data["profiles"] + if isinstance(profiles, (str, bytes)) or not isinstance(profiles, Sequence): + raise FabricConfigError(f"{owner} profiles must be an ordered sequence of strings") + return tuple(_required_text(profile, f"{owner} profile") for profile in profiles) + + +def _boolean(value: Any, name: str) -> bool: + if not isinstance(value, bool): + raise FabricConfigError(f"{name} must be a boolean") + return value + + +def _coerce(model: type[_T], value: _T | Mapping[str, Any], name: str) -> _T: + if isinstance(value, model): + return deepcopy(value) + if isinstance(value, Mapping): + return model.from_mapping(value) # type: ignore[attr-defined,no-any-return] + raise FabricConfigError(f"{name} must be a {model.__name__} or JSON object") + + +class _ConfigMapping(dict[str, Any]): + """Mutable schema-shaped config with explicit extension storage.""" + + _fields: frozenset[str] = frozenset() + _omit_if_empty: frozenset[str] = frozenset() + + def __init__( + self, + values: Mapping[str, Any], + *, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + extras = _mapping({} if extra_fields is None else extra_fields, "extra_fields") + overlap = self._fields.intersection(extras) + if overlap: + raise FabricConfigError( + f"extra_fields duplicates known fields: {', '.join(sorted(overlap))}" + ) + stored = { + key: deepcopy(item) if isinstance(item, _ConfigMapping) else _plain(item) + for key, item in values.items() + } + super().__init__({**stored, **extras}) + + def __getattr__(self, name: str) -> Any: + try: + return self[name] + except KeyError as error: + if name in self._fields: + return None + raise AttributeError(name) from error + + def __setattr__(self, name: str, value: Any) -> None: + if name.startswith("_"): + object.__setattr__(self, name, value) + return + if name not in self._fields: + raise AttributeError(name) + self[name] = deepcopy(value) if isinstance(value, _ConfigMapping) else _plain(value) + + @property + def extra_fields(self) -> dict[str, Any]: + return { + key: _plain(value) + for key, value in self.items() + if key not in self._fields + } + + def to_mapping(self) -> dict[str, Any]: + data = _plain(dict(self)) + for key in self._omit_if_empty: + if data.get(key) in ({}, []): + data.pop(key, None) + return data + + +class MetadataConfig(_ConfigMapping): + """Agent identity and human-readable metadata.""" + + _fields = frozenset({"name", "description"}) + + def __init__( + self, + *, + name: str, + description: str | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + values: dict[str, Any] = {"name": _required_text(name, "metadata name")} + if description is not None: + values["description"] = description + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "MetadataConfig": + data = _mapping(value, "metadata") + return cls( + name=data.get("name"), + description=data.get("description"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +class HarnessConfig(_ConfigMapping): + """Harness adapter selection and adapter-owned settings.""" + + _fields = frozenset({"adapter_id", "resolution", "settings"}) + _omit_if_empty = frozenset({"settings"}) + + def __init__( + self, + *, + adapter_id: str, + resolution: str | None = None, + settings: Mapping[str, Any] | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + values: dict[str, Any] = { + "adapter_id": _required_text(adapter_id, "adapter_id"), + "settings": _mapping( + {} if settings is None else settings, + "harness settings", + ), + } + if resolution is not None: + values["resolution"] = resolution + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "HarnessConfig": + data = _mapping(value, "harness") + return cls( + adapter_id=data.get("adapter_id"), + resolution=data.get("resolution"), + settings=data.get("settings"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +class RuntimeConfig(_ConfigMapping): + """Runtime lifecycle mode and input/output contract.""" + + _fields = frozenset( + {"mode", "transport", "input_schema", "output_schema", "artifacts"} + ) + + def __init__( + self, + *, + mode: str = "oneshot", + transport: str | None = None, + input_schema: str | None = None, + output_schema: str | None = None, + artifacts: str | Path | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + if mode not in {"oneshot", "session", "service"}: + raise FabricConfigError(f"unsupported runtime mode: {mode!r}") + values: dict[str, Any] = {"mode": mode} + for key, item in ( + ("transport", transport), + ("input_schema", input_schema), + ("output_schema", output_schema), + ("artifacts", artifacts), + ): + if item is not None: + values[key] = item + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "RuntimeConfig": + data = _mapping(value, "runtime") + return cls( + mode=data.get("mode", "oneshot"), + transport=data.get("transport"), + input_schema=data.get("input_schema"), + output_schema=data.get("output_schema"), + artifacts=data.get("artifacts"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +class EnvironmentConfig(_ConfigMapping): + """Execution environment configuration.""" + + _fields = frozenset( + {"provider", "workspace", "artifacts", "settings", "metadata"} + ) + _omit_if_empty = frozenset({"settings", "metadata"}) + + def __init__( + self, + *, + provider: str = "local", + workspace: str | Path | None = None, + artifacts: str | Path | None = None, + settings: Mapping[str, Any] | None = None, + metadata: Mapping[str, Any] | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + values: dict[str, Any] = { + "provider": _required_text(provider, "environment provider"), + "settings": _mapping( + {} if settings is None else settings, + "environment settings", + ), + "metadata": _mapping( + {} if metadata is None else metadata, + "environment metadata", + ), + } + if workspace is not None: + values["workspace"] = workspace + if artifacts is not None: + values["artifacts"] = artifacts + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "EnvironmentConfig": + data = _mapping(value, "environment") + return cls( + provider=data.get("provider", "local"), + workspace=data.get("workspace"), + artifacts=data.get("artifacts"), + settings=data.get("settings"), + metadata=data.get("metadata"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +class FabricConfig(_ConfigMapping): + """Mutable typed SDK object for a Fabric agent config.""" + + _fields = frozenset( + { + "schema_version", + "metadata", + "harness", + "runtime", + "environment", + "models", + "mcp", + "skills", + "telemetry", + "profiles", + "tools", + } + ) + _omit_if_empty = frozenset({"models"}) + + def __init__( + self, + *, + metadata: MetadataConfig | Mapping[str, Any], + harness: HarnessConfig | Mapping[str, Any], + runtime: RuntimeConfig | Mapping[str, Any] | None = None, + schema_version: str = "fabric.agent/v1alpha1", + environment: EnvironmentConfig | Mapping[str, Any] | None = None, + models: Mapping[str, Any] | None = None, + mcp: Mapping[str, Any] | None = None, + skills: Mapping[str, Any] | None = None, + telemetry: Mapping[str, Any] | None = None, + profiles: Mapping[str, Any] | None = None, + tools: Any = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + metadata_value = _coerce(MetadataConfig, metadata, "metadata") + harness_value = _coerce(HarnessConfig, harness, "harness") + runtime_value = _coerce( + RuntimeConfig, + RuntimeConfig() if runtime is None else runtime, + "runtime", + ) + environment_value = ( + None + if environment is None + else _coerce(EnvironmentConfig, environment, "environment") + ) + values: dict[str, Any] = { + "schema_version": _required_text(schema_version, "schema_version"), + "metadata": metadata_value, + "harness": harness_value, + "runtime": runtime_value, + "models": _mapping({} if models is None else models, "models"), + } + for key, item in ( + ("environment", environment_value), + ("mcp", mcp), + ("skills", skills), + ("telemetry", telemetry), + ("profiles", profiles), + ("tools", tools), + ): + if item is not None: + values[key] = item + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "FabricConfig": + data = _mapping(value, "FabricConfig") + if "metadata" not in data: + raise FabricConfigError("FabricConfig metadata is required") + if "harness" not in data: + raise FabricConfigError("FabricConfig harness is required") + return cls( + schema_version=data.get("schema_version", "fabric.agent/v1alpha1"), + metadata=data["metadata"], + harness=data["harness"], + runtime=data.get("runtime"), + environment=data.get("environment"), + models=data.get("models"), + mcp=data.get("mcp"), + skills=data.get("skills"), + telemetry=data.get("telemetry"), + profiles=data.get("profiles"), + tools=data.get("tools"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +class FabricProfileConfig(_ConfigMapping): + """Mutable typed SDK object for an in-memory Fabric profile.""" + + _fields = frozenset( + { + "schema_version", + "name", + "description", + "harness", + "runtime", + "environment", + "models", + "mcp", + "skills", + "telemetry", + "tools", + } + ) + + def __init__( + self, + *, + name: str, + schema_version: str = "fabric.profile/v1alpha1", + description: str | None = None, + harness: HarnessConfig | Mapping[str, Any] | None = None, + runtime: RuntimeConfig | Mapping[str, Any] | None = None, + environment: EnvironmentConfig | Mapping[str, Any] | None = None, + models: Mapping[str, Any] | None = None, + mcp: Mapping[str, Any] | None = None, + skills: Mapping[str, Any] | None = None, + telemetry: Mapping[str, Any] | None = None, + tools: Any = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + values: dict[str, Any] = { + "schema_version": _required_text(schema_version, "schema_version"), + "name": _required_text(name, "profile name"), + } + if description is not None: + values["description"] = description + for key, item in ( + ("harness", harness), + ("runtime", runtime), + ("environment", environment), + ): + if item is not None: + values[key] = ( + deepcopy(item) + if isinstance(item, _ConfigMapping) + else _mapping(item, key) + ) + for key, item in ( + ("models", models), + ("mcp", mcp), + ("skills", skills), + ("telemetry", telemetry), + ("tools", tools), + ): + if item is not None: + values[key] = item + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "FabricProfileConfig": + data = _mapping(value, "FabricProfileConfig") + return cls( + schema_version=data.get("schema_version", "fabric.profile/v1alpha1"), + name=data.get("name"), + description=data.get("description"), + harness=data.get("harness"), + runtime=data.get("runtime"), + environment=data.get("environment"), + models=data.get("models"), + mcp=data.get("mcp"), + skills=data.get("skills"), + telemetry=data.get("telemetry"), + tools=data.get("tools"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +def _freeze(value: Any) -> Any: + if isinstance(value, FabricMapping): + return value + if isinstance(value, _ConfigMapping): + return deepcopy(value) + if isinstance(value, Mapping): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze(item) for item in value) + if isinstance(value, tuple): + return tuple(_freeze(item) for item in value) + return value + + +def _thaw(value: Any) -> Any: + if isinstance(value, _ConfigMapping): + return value.to_mapping() + if isinstance(value, FabricMapping): + return value.to_mapping() + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw(item) for item in value] + if isinstance(value, Path): + return str(value) + return deepcopy(value) + + +def _snapshot_value(value: Any, *, json_value: bool) -> Any: + if isinstance(value, _ConfigMapping): + return deepcopy(value) + if json_value: + return _thaw(value) + return value + + +class FabricMapping(Mapping[str, Any]): + """Immutable mapping-compatible base for SDK snapshots and results.""" + + _fields: frozenset[str] = frozenset() + _json_fields: frozenset[str] = frozenset() + _omit_if_empty: frozenset[str] = frozenset() + + def __init__(self, mapping: Mapping[str, Any]) -> None: + data = self._normalize(_mapping(mapping, type(self).__name__)) + object.__setattr__(self, "_data", _freeze(data)) + + @classmethod + def from_mapping(cls, mapping: Mapping[str, Any]) -> "FabricMapping": + return cls(mapping) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + return data + + def __getitem__(self, key: str) -> Any: + return _snapshot_value( + self._data[key], + json_value=key in self._json_fields or key not in self._fields, + ) + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def __getattr__(self, name: str) -> Any: + try: + return _snapshot_value( + self._data[name], + json_value=name in self._json_fields or name not in self._fields, + ) + except KeyError as error: + raise AttributeError(name) from error + + @property + def extra_fields(self) -> Mapping[str, Any]: + return MappingProxyType( + { + key: _thaw(value) + for key, value in self._data.items() + if key not in self._fields + } + ) + + def to_mapping(self) -> dict[str, Any]: + data = _thaw(self._data) + for key in self._omit_if_empty: + if data.get(key) in ({}, []): + data.pop(key, None) + return data + + def to_dict(self) -> dict[str, Any]: + return self.to_mapping() + + +class AdapterInfo(FabricMapping): + adapter_id: str + harness: str + adapter_kind: str + metadata: Mapping[str, Any] + _fields = frozenset({"adapter_id", "harness", "adapter_kind", "metadata"}) + _json_fields = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["adapter_id"] = _required_text(data.get("adapter_id"), "adapter_id") + data["harness"] = _required_text(data.get("harness"), "harness") + data["adapter_kind"] = _required_text(data.get("adapter_kind"), "adapter_kind") + data["metadata"] = _mapping(data.get("metadata", {}), "adapter metadata") + return data + + +class RuntimeCapabilities(FabricMapping): + session: bool + service: bool + streaming: bool + updates: bool + cancellation: bool + concurrent_invocations: bool + metadata: Mapping[str, Any] + _fields = frozenset( + { + "session", + "service", + "streaming", + "updates", + "cancellation", + "concurrent_invocations", + "metadata", + } + ) + _json_fields = frozenset({"metadata"}) + _omit_if_empty = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + for field in cls._fields - {"metadata"}: + data[field] = _boolean(data.get(field, False), f"{field} capability") + data["metadata"] = _mapping(data.get("metadata", {}), "capability metadata") + return data + + +class EffectiveConfig(FabricMapping): + agent_name: str + profiles: Sequence[str] + agent_root: Path + config_path: Path | None + config_root: Path + config: FabricConfig + _fields = frozenset( + {"agent_name", "profiles", "agent_root", "config_path", "config_root", "config"} + ) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["profiles"] = _required_profiles(data, "EffectiveConfig") + data["agent_root"] = Path(data.get("agent_root", ".")) + data["config_root"] = Path(data.get("config_root", ".")) + data["config_path"] = ( + None if data.get("config_path") is None else Path(data["config_path"]) + ) + data["config"] = FabricConfig.from_mapping(data.get("config", {})) + return data + + +class RunPlan(FabricMapping): + effective_config: EffectiveConfig + agent_name: str + profiles: Sequence[str] + adapter: AdapterInfo + capabilities: RuntimeCapabilities + _fields = frozenset( + {"effective_config", "agent_name", "profiles", "adapter", "capabilities"} + ) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + descriptor = data.get("adapter") + if descriptor is None: + descriptor = (data.get("adapter_descriptor") or {}).get("descriptor", {}) + data["effective_config"] = EffectiveConfig.from_mapping(data["effective_config"]) + data["profiles"] = _required_profiles(data, "RunPlan") + data["adapter"] = AdapterInfo.from_mapping(descriptor) + data["capabilities"] = RuntimeCapabilities.from_mapping(data.get("capabilities", {})) + return data + + +class DoctorCheck(FabricMapping): + name: str + status: str + message: str + metadata: Mapping[str, Any] + _fields = frozenset({"name", "status", "message", "metadata"}) + _json_fields = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["metadata"] = _mapping(data.get("metadata", {}), "doctor metadata") + return data + + +class DoctorReport(FabricMapping): + agent_name: str + profiles: Sequence[str] + status: str + checks: Sequence[DoctorCheck] + _fields = frozenset({"agent_name", "profiles", "status", "checks"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["profiles"] = _required_profiles(data, "DoctorReport") + data["checks"] = tuple( + DoctorCheck.from_mapping(check) for check in data.get("checks", []) + ) + return data + + +class RunRequest(FabricMapping): + input: Any + request_id: str + context: Mapping[str, Any] + overrides: Mapping[str, Any] | None + _fields = frozenset({"input", "request_id", "context", "overrides"}) + _json_fields = frozenset({"input", "context", "overrides"}) + + def __init__( + self, + *, + input: Any = _UNSET, + request_id: str | None = None, + context: Mapping[str, Any] | None = None, + overrides: Mapping[str, Any] | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + data: dict[str, Any] = { + "input": "" if input is _UNSET or input is None else input, + "request_id": request_id or f"request-{uuid.uuid4().hex}", + "context": _mapping( + {} if context is None else context, + "request context", + ), + } + if overrides is not None: + data["overrides"] = _mapping(overrides, "request overrides") + extras = _mapping( + {} if extra_fields is None else extra_fields, + "request extra_fields", + ) + overlap = self._fields.intersection(extras) + if overlap: + raise FabricConfigError( + f"request extra_fields duplicates known fields: {', '.join(sorted(overlap))}" + ) + data.update(extras) + FabricMapping.__init__(self, data) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "RunRequest": + data = _mapping(value, "RunRequest") + return cls( + input=data.get("input", _UNSET), + request_id=data.get("request_id"), + context=data.get("context"), + overrides=data.get("overrides"), + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + +class ErrorInfo(FabricMapping): + stage: str + code: str + message: str + retryable: bool + metadata: Mapping[str, Any] + _fields = frozenset({"stage", "code", "message", "retryable", "metadata"}) + _json_fields = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["metadata"] = _mapping(data.get("metadata", {}), "error metadata") + return data + + +class ArtifactRef(FabricMapping): + name: str + kind: str + path: Path + media_type: str | None + metadata: Mapping[str, Any] + _fields = frozenset({"name", "kind", "path", "media_type", "metadata"}) + _json_fields = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["path"] = Path(data["path"]) + data["metadata"] = _mapping(data.get("metadata", {}), "artifact metadata") + return data + + +class ArtifactManifest(FabricMapping): + root: Path | None + artifacts: Sequence[ArtifactRef] + _fields = frozenset({"root", "artifacts"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["root"] = None if data.get("root") is None else Path(data["root"]) + data["artifacts"] = tuple( + ArtifactRef.from_mapping(artifact) for artifact in data.get("artifacts", []) + ) + return data + + +class TelemetryRef(FabricMapping): + provider: str + kind: str + uri: str | None + trace_id: str | None + metadata: Mapping[str, Any] + _fields = frozenset({"provider", "kind", "uri", "trace_id", "metadata"}) + _json_fields = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + metadata = _mapping(data.get("metadata", {}), "telemetry metadata") + if "relay_enabled" in data: + metadata.setdefault("relay_enabled", data["relay_enabled"]) + data = { + "provider": "relay", + "kind": "trace", + "uri": metadata.get("relay_output_dir"), + "trace_id": metadata.get("trace_id"), + "metadata": metadata, + } + else: + data.setdefault("uri", None) + data.setdefault("trace_id", None) + data["metadata"] = metadata + return data + + +class FabricEvent(FabricMapping): + event_id: str + timestamp_millis: int + kind: str + message: str + metadata: Mapping[str, Any] + _fields = frozenset({"event_id", "timestamp_millis", "kind", "message", "metadata"}) + _json_fields = frozenset({"metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["metadata"] = _mapping(data.get("metadata", {}), "event metadata") + return data + + +class RuntimeHandle(FabricMapping): + runtime_id: str + runtime_binding: str + agent_name: str + harness: str + mode: str + adapter_kind: str + adapter_id: str | None + environment: Mapping[str, Any] + _fields = frozenset( + { + "runtime_id", + "runtime_binding", + "agent_name", + "harness", + "mode", + "adapter_kind", + "adapter_id", + "environment", + } + ) + _json_fields = frozenset({"environment"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + for field in ( + "runtime_id", + "runtime_binding", + "agent_name", + "harness", + "mode", + "adapter_kind", + ): + data[field] = _required_text(data.get(field), field.replace("_", " ")) + if data.get("adapter_id") is not None: + data["adapter_id"] = _required_text(data["adapter_id"], "adapter id") + data["environment"] = _mapping(data.get("environment"), "environment") + return data + + +class RunResult(FabricMapping): + agent_name: str + profiles: Sequence[str] + harness: str + adapter_kind: str + adapter_id: str + runtime_id: str + invocation_id: str + request_id: str + status: str + output: Any + error: ErrorInfo | None + artifacts: ArtifactManifest + telemetry: Sequence[TelemetryRef] + events: Sequence[FabricEvent] + metadata: Mapping[str, Any] + _fields = frozenset( + { + "agent_name", + "profiles", + "harness", + "adapter_kind", + "adapter_id", + "runtime_id", + "invocation_id", + "request_id", + "status", + "output", + "error", + "artifacts", + "telemetry", + "events", + "metadata", + } + ) + _json_fields = frozenset({"output", "metadata"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["profiles"] = _required_profiles(data, "RunResult") + for field in ( + "agent_name", + "harness", + "adapter_kind", + "runtime_id", + "invocation_id", + "request_id", + "status", + ): + data[field] = _required_text(data.get(field), field.replace("_", " ")) + data["error"] = ( + None if data.get("error") is None else ErrorInfo.from_mapping(data["error"]) + ) + data["artifacts"] = ArtifactManifest.from_mapping( + data.get("artifacts", {"artifacts": []}) + ) + telemetry = data.get("telemetry") + if telemetry is None: + data["telemetry"] = () + elif isinstance(telemetry, Mapping): + data["telemetry"] = (TelemetryRef.from_mapping(telemetry),) + else: + data["telemetry"] = tuple( + TelemetryRef.from_mapping(item) for item in telemetry + ) + data["events"] = tuple( + FabricEvent.from_mapping(event) for event in data.get("events", []) + ) + data["metadata"] = _mapping(data.get("metadata", {}), "result metadata") + return data + + +class SessionInfo(FabricMapping): + session_id: str + runtime_id: str + agent_name: str + profiles: Sequence[str] + harness: str + adapter_id: str + adapter_kind: str + status: str + capabilities: RuntimeCapabilities + _fields = frozenset( + { + "session_id", + "runtime_id", + "agent_name", + "profiles", + "harness", + "adapter_id", + "adapter_kind", + "status", + "capabilities", + } + ) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["profiles"] = _required_profiles(data, "SessionInfo") + data["capabilities"] = RuntimeCapabilities.from_mapping(data.get("capabilities", {})) + return data + + +class RuntimeUpdate(FabricMapping): + overrides: Mapping[str, Any] + metadata: Mapping[str, Any] + _fields = frozenset({"overrides", "metadata"}) + _json_fields = frozenset({"overrides", "metadata"}) + + +class RuntimeUpdateResult(FabricMapping): + status: str + applied: Mapping[str, Any] + rejected: Mapping[str, Any] + reason: str | None + _fields = frozenset({"status", "applied", "rejected", "reason"}) + _json_fields = frozenset({"applied", "rejected"}) diff --git a/python/tests/smoke_environment_handle.py b/python/tests/smoke_environment_handle.py index 3155c305f..a57f7400c 100644 --- a/python/tests/smoke_environment_handle.py +++ b/python/tests/smoke_environment_handle.py @@ -19,7 +19,10 @@ async def main() -> None: async with FabricClient() as client: - session = await client.start(ROOT / "examples" / "code-review-agent", profile="env_local") + session = await client.start_session( + ROOT / "examples" / "code-review-agent", + profiles=["env_local"], + ) try: workspace = session.runtime["environment"]["workspace"] finally: diff --git a/python/tests/smoke_harbor_integration.py b/python/tests/smoke_harbor_integration.py index 6aa958ebd..3d3719f02 100644 --- a/python/tests/smoke_harbor_integration.py +++ b/python/tests/smoke_harbor_integration.py @@ -93,8 +93,8 @@ async def exec( "runtime_id": "runtime-1", "invocation_id": "invocation-1", "request_id": "harbor-request-1", - "profile": "env_local", - "harness_type": "hermes", + "profiles": ["env_local", "mcp_github"], + "harness": "hermes", "adapter_id": "nvidia.fabric.hermes.sdk", "artifacts": { "root": "/workspace/agent/artifacts", @@ -148,6 +148,7 @@ async def main() -> None: assert "--profile env_local --profile mcp_github" in fabric_commands[0] assert context.metadata assert context.metadata["fabric"]["status"] == "succeeded" + assert context.metadata["fabric"]["profiles"] == ["env_local", "mcp_github"] assert context.metadata["fabric"]["adapter_id"] == "nvidia.fabric.hermes.sdk" artifacts = context.metadata["fabric"]["artifacts"]["artifacts"] assert {artifact["name"] for artifact in artifacts} == {"stdout", "workspace_patch"} diff --git a/python/tests/smoke_native_sdk.py b/python/tests/smoke_native_sdk.py index 2983df34c..fe89929e2 100644 --- a/python/tests/smoke_native_sdk.py +++ b/python/tests/smoke_native_sdk.py @@ -11,21 +11,11 @@ from shutil import copytree import nemo_fabric._native as native -from nemo_fabric import FabricClient +from nemo_fabric import FabricClient, FabricConfig, FabricProfileConfig ROOT = Path(__file__).resolve().parents[2] -class ModelDumpLike: - def __init__(self, value: dict) -> None: - self.value = value - - def model_dump(self, *, mode: str, exclude_none: bool) -> dict: - assert mode == "json" - assert exclude_none is True - return self.value - - async def main() -> None: assert native.version() @@ -37,23 +27,34 @@ async def smoke(client: FabricClient) -> None: example_agent = ROOT / "examples" / "code-review-agent" fixture_agent = ROOT / "tests" / "fixtures" / "hermes-shim-agent" - assert client.validate(example_agent).startswith("validated") - inspected = client.inspect(example_agent, profile="env_local") + inspected = client.resolve(example_agent, profiles=["env_local"]) assert inspected["agent_name"] == "code-review-agent" - assert inspected["profiles"] == ["env_local"] + assert inspected.profiles == ("env_local",) assert inspected["config"]["metadata"]["name"] == "code-review-agent" - plan = client.plan(example_agent, profile="env_local") + plan = client.plan(example_agent, profiles=["env_local"]) assert plan["agent_name"] == "code-review-agent" assert plan["adapter_descriptor"]["descriptor"]["adapter_id"] == "nvidia.fabric.hermes.sdk" assert plan["capability_plan"]["native"]["mcp_servers"]["github"] assert plan["capability_plan"]["native"]["skill_paths"] - multi_plan = client.plan(fixture_agent, profile=["env_local", "mcp_github"]) - assert multi_plan["profiles"] == ["env_local", "mcp_github"] + multi_plan = client.plan(fixture_agent, profiles=["env_local", "mcp_github"]) + assert multi_plan.profiles == ("env_local", "mcp_github") assert multi_plan["telemetry_plan"]["relay_enabled"] is True - typed_config = ModelDumpLike( + minimal = FabricConfig.from_mapping( + { + "metadata": {"name": "minimal-typed-agent"}, + "harness": {"adapter_id": "nvidia.fabric.hermes.sdk"}, + } + ) + minimal_resolved = client.resolve(minimal) + assert minimal_resolved.config.runtime.mode == "oneshot" + assert minimal_resolved.config.runtime.transport == "library" + assert minimal_resolved.config.runtime.input_schema == "text" + assert minimal_resolved.config.runtime.output_schema == "text" + + typed_config = FabricConfig.from_mapping( { "schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "typed-hermes-shim-agent"}, @@ -91,38 +92,75 @@ async def smoke(client: FabricClient) -> None: } }, "telemetry": {"enabled": False}, + "consumer_extension": { + "base": True, + "nested": {"first": 1}, + }, } ) - typed_profile = { - "name": "typed_relay", - "telemetry": {"enabled": True, "output_dir": "./artifacts/relay"}, - } - typed_plan = client.plan_config( + typed_profile = FabricProfileConfig.from_mapping( + { + "name": "typed_relay", + "harness": {"settings": {"timeout_seconds": 30}}, + "telemetry": {"enabled": True, "output_dir": "./artifacts/relay"}, + "consumer_extension": { + "profile": True, + "nested": {"second": 2}, + }, + } + ) + typed_config_resolved = client.resolve( typed_config, - profile_configs=[typed_profile], + profiles=[typed_profile], base_dir=fixture_agent, ) + typed_plan = client.plan( + typed_config, + profiles=[typed_profile], + base_dir=fixture_agent, + ) + assert typed_config_resolved.agent_name == "typed-hermes-shim-agent" assert typed_plan["agent_name"] == "typed-hermes-shim-agent" - assert typed_plan["profile"] == "typed_relay" + assert typed_plan.profiles == ("typed_relay",) assert typed_plan["adapter_descriptor"]["source"] == "local" assert typed_plan["telemetry_plan"]["relay_enabled"] is True + resolved_config = typed_config_resolved.config.to_mapping() + assert resolved_config["harness"]["adapter_id"] == "test.fabric.hermes_shim" + assert resolved_config["harness"]["settings"]["workspace"] == "./repos/my-service" + assert resolved_config["harness"]["settings"]["timeout_seconds"] == 30 + assert resolved_config["consumer_extension"] == { + "base": True, + "profile": True, + "nested": {"first": 1, "second": 2}, + } with tempfile.TemporaryDirectory(prefix="fabric-native-sdk-") as tmpdir: temp_agent = Path(tmpdir) / "hermes-shim-agent" copytree(fixture_agent, temp_agent) - result = await client.run(temp_agent, profile="env_local", input_text="hello native") - async with await client.start(temp_agent, profile="env_local") as session: - first = await session.invoke("hello session one") - second = await session.invoke("hello session two") + result = await client.run( + temp_agent, + profiles=["env_local"], + input="hello native", + ) + async with await client.start_session( + temp_agent, + profiles=["env_local"], + ) as session: + first = await session.invoke(input="hello session one") + second = await session.invoke(input="hello session two") assert result["status"] == "succeeded" + assert result.profiles == ("env_local",) + assert result.harness == "hermes" assert result["adapter_kind"] == "python" assert result["metadata"]["adapter_runner"] == "python" assert result["output"]["received"] == "hello native" - assert result["output"]["native_mcp_servers"] == ["github"] - assert any(artifact["name"] == "stdout" for artifact in result["artifacts"]["artifacts"]) + assert result.output["native_mcp_servers"] == ["github"] + assert any(artifact.name == "stdout" for artifact in result.artifacts.artifacts) assert first["status"] == "succeeded" assert second["status"] == "succeeded" + assert first.profiles == ("env_local",) + assert first.harness == "hermes" assert first["runtime_id"] == second["runtime_id"] assert session.runtime["runtime_id"] == first["runtime_id"] diff --git a/python/tests/smoke_readme_examples.py b/python/tests/smoke_readme_examples.py index 8b1b522ff..ea98cb8f7 100644 --- a/python/tests/smoke_readme_examples.py +++ b/python/tests/smoke_readme_examples.py @@ -1,22 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Smoke test: the README "Use Fabric" examples stay accurate and runnable. - -WS4 guardrail. The documented Python SDK examples (``plan`` / ``doctor`` / -``plan_config`` and the source-tree CLI-command form) are mirrored here and run -against the real example agent, so an API change breaks this test instead of -silently rotting the README. A drift guard additionally asserts the README still -contains each documented invocation verbatim, keeping the prose and the -executable mirror in sync. The CLI snippets are exercised by ``tests/smoke_cli.py``. -""" +"""Smoke test: the README "Use Fabric" examples stay accurate and runnable.""" from __future__ import annotations import asyncio from pathlib import Path -from nemo_fabric import FabricClient +from nemo_fabric import FabricClient, FabricConfig ROOT = Path(__file__).resolve().parents[2] README = ROOT / "README.md" @@ -28,23 +20,27 @@ "fabric plan examples/code-review-agent --profile hermes_sdk", "fabric plan examples/code-review-agent --profile env_local --profile mcp_github", "fabric doctor examples/code-review-agent --profile hermes_sdk", - 'plan = client.plan(agent, profile="hermes_sdk")', - 'report = await client.doctor(agent, profile="hermes_sdk")', - "plan = client.plan_config(", + 'plan = client.plan(agent, profiles=["hermes_sdk"])', + 'report = await client.doctor(agent, profiles=["hermes_sdk"])', + "config = FabricConfig.from_mapping(", + "plan = client.plan(", + "result = await client.run(", '"harness": {"adapter_id": "nvidia.fabric.hermes.sdk"},', 'base_dir="examples/code-review-agent",', "### Multi-Turn SDK Sessions", "### Interactive CLI Chat", + "FabricClient().start_session(", + 'profiles=["hermes_session"],', 'session_id="review-session-123",', "fabric chat examples/code-review-agent \\", "--profile hermes_cli_session", "--session-id review-session-123", "--verbose", "requires `runtime.mode: session`; use `fabric run`", - 'client = FabricClient(command=("cargo", "run", "-q", "-p", "fabric-cli", "--"))', + "The CLI is a separate interface over the same Rust", ] -# The exact typed-config dict shown in the README "plan_config" example. +# The exact typed-config dict shown in the README example. README_PLAN_CONFIG = { "schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "code-review-agent"}, @@ -77,22 +73,18 @@ async def readme_python_examples_run() -> None: agent = EXAMPLE_AGENT async with FabricClient() as client: - plan = client.plan(agent, profile="hermes_sdk") - report = await client.doctor(agent, profile="hermes_sdk") - typed_plan = client.plan_config(README_PLAN_CONFIG, base_dir=agent) + plan = client.plan(agent, profiles=["hermes_sdk"]) + report = await client.doctor(agent, profiles=["hermes_sdk"]) + typed_plan = client.plan( + FabricConfig.from_mapping(README_PLAN_CONFIG), + base_dir=agent, + ) # README prints plan["agent_name"] and report["checks"]. assert plan["agent_name"] == "code-review-agent", plan["agent_name"] assert report["checks"], "doctor returned no checks" assert typed_plan["agent_name"] == "code-review-agent" - assert ( - typed_plan["adapter_descriptor"]["descriptor"]["adapter_id"] - == "nvidia.fabric.hermes.sdk" - ) - - # The documented source-tree form selects the CLI command path. - cli_client = FabricClient(command=("cargo", "run", "-q", "-p", "fabric-cli", "--")) - assert cli_client.command == ("cargo", "run", "-q", "-p", "fabric-cli", "--") + assert typed_plan.adapter.adapter_id == "nvidia.fabric.hermes.sdk" def main() -> None: diff --git a/python/tests/smoke_sdk.py b/python/tests/smoke_sdk.py index 09c110f91..755c73bbd 100644 --- a/python/tests/smoke_sdk.py +++ b/python/tests/smoke_sdk.py @@ -1,197 +1,57 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Smoke test for the POC Python SDK.""" +"""Dependency-free smoke for the importable public SDK contract.""" from __future__ import annotations -import asyncio -import json -import subprocess import sys -import tempfile -from shutil import copytree from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "python" / "src")) -sys.path.insert(0, str(ROOT / "tests")) -from _utils.utils import ( # noqa: E402 - assert_process_adapter_native_observability, - assert_relay_disabled_native_observability, +from nemo_fabric import ( # noqa: E402 + FabricClient, + FabricConfig, + FabricNativeUnavailableError, + HarnessConfig, + MetadataConfig, + RunRequest, + RuntimeConfig, ) -from nemo_fabric import FabricClient - -COMMAND = ("cargo", "run", "-q", "-p", "fabric-cli", "--") - - -async def main() -> None: - async with FabricClient( - command=COMMAND, - cwd=ROOT, - ) as client: - await smoke(client) - - -async def smoke(client: FabricClient) -> None: - example_agent = ROOT / "examples" / "code-review-agent" - fixture_agent = ROOT / "tests" / "fixtures" / "hermes-shim-agent" - process_fixture_agent = ROOT / "tests" / "fixtures" / "hermes-cli-agent" - - assert client.validate(example_agent).startswith("validated") - - plan = client.plan(example_agent, profile="env_local") - assert plan["agent_name"] == "code-review-agent" - assert plan["adapter_descriptor"]["source"] == "repository" - assert plan["adapter_descriptor"]["descriptor"]["adapter_id"] == "nvidia.fabric.hermes.sdk" - assert plan["environment_plan"]["provider"] == "local" - - report = await client.doctor(fixture_agent, profile="env_local") - assert report["agent_name"] == "hermes-shim-agent" - assert report["checks"] - - multi_plan = client.plan(fixture_agent, profile=("env_local", "mcp_github")) - assert multi_plan["profiles"] == ["env_local", "mcp_github"] - assert "profile" not in multi_plan - assert multi_plan["telemetry_plan"]["relay_enabled"] is True - - with tempfile.TemporaryDirectory(prefix="fabric-python-sdk-") as tmpdir: - temp_agent = Path(tmpdir) / "hermes-shim-agent-sdk" - temp_cli_agent = Path(tmpdir) / "hermes-shim-agent-cli" - temp_process_agent = Path(tmpdir) / "hermes-cli-agent-sdk" - temp_process_cli_agent = Path(tmpdir) / "hermes-cli-agent-cli" - copytree(fixture_agent, temp_agent) - copytree(fixture_agent, temp_cli_agent) - copytree(process_fixture_agent, temp_process_agent) - copytree(process_fixture_agent, temp_process_cli_agent) - - hermes_result = await client.run( - temp_agent, - profile="env_local", - input_text="hello hermes", - ) - hermes_cli_result = call_json( - "run", - temp_cli_agent, - "--profile", - "env_local", - "--input", - "hello hermes", - ) - structured = await client.run( - temp_agent, - profile="env_local", - request={ - "request_id": "sdk-structured-request", - "input": "hello structured sdk", - "context": {"task": {"source": "sdk-smoke"}}, - }, - ) - process_result = await client.run( - temp_process_agent, - profile="env_local", - input_text="hello process adapter", - ) - process_cli_result = call_json( - "run", - temp_process_cli_agent, - "--profile", - "env_local", - "--input", - "hello process adapter", - ) - - assert_sdk_cli_runresult_parity( - hermes_cli_result, - hermes_result, - adapter_kind="python", - adapter_id="test.fabric.hermes_shim", - adapter_runner="python", - mode="shim", - ) - assert_sdk_cli_runresult_parity( - process_cli_result, - process_result, - adapter_kind="process", - adapter_id="nvidia.fabric.hermes.cli", - adapter_runner="process", - mode="hermes_cli_oneshot", - ) - assert_relay_disabled_native_observability(hermes_result) - assert_process_adapter_native_observability(process_result) - - assert hermes_result["status"] == "succeeded" - assert hermes_result["adapter_kind"] == "python" - assert hermes_result["output"]["harness"] == "hermes" - assert hermes_result["output"]["received"] == "hello hermes" - assert hermes_result["output"]["native_skill_paths"] - assert hermes_result["output"]["native_mcp_servers"] == ["github"] - assert hermes_result["output"]["managed_skill_paths"] == [] - assert hermes_result["output"]["managed_mcp_servers"] == [] - - assert structured["request_id"] == "sdk-structured-request" - assert structured["output"]["received"] == "hello structured sdk" - - process_response = json.loads(process_result["output"]["response"]) - assert process_response["fake_hermes"] is True - assert process_response["prompt"] == "hello process adapter" - - -def assert_sdk_cli_runresult_parity( - cli_result: dict, - sdk_result: dict, - *, - adapter_kind: str, - adapter_id: str, - adapter_runner: str, - mode: str, -) -> None: - comparable_fields = [ - "agent_name", - "profile", - "harness_type", - "adapter_kind", - "adapter_id", - "status", - ] - for field in comparable_fields: - assert cli_result[field] == sdk_result[field], field - - assert cli_result.get("error") == sdk_result.get("error") - assert cli_result["adapter_kind"] == adapter_kind - assert cli_result["adapter_id"] == adapter_id - assert cli_result["metadata"]["adapter_runner"] == adapter_runner - assert sdk_result["metadata"]["adapter_runner"] == adapter_runner - assert cli_result["output"]["harness"] == "hermes" - assert sdk_result["output"]["harness"] == "hermes" - assert cli_result["output"]["mode"] == mode - assert sdk_result["output"]["mode"] == mode - - for result in (cli_result, sdk_result): - assert result["status"] == "succeeded" - assert result["runtime_id"].startswith("runtime-") - assert result["invocation_id"].startswith("invocation-") - assert result["request_id"].startswith("request-") - assert isinstance(result["artifacts"]["artifacts"], list) - assert isinstance(result["events"], list) - assert result["events"], "RunResult events should not be empty" +from nemo_fabric import client as client_mod # noqa: E402 + + +def main() -> None: + config = FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig( + adapter_id="test.fabric.shim", + settings={"future_adapter_option": True}, + ), + runtime=RuntimeConfig(mode="oneshot"), + extra_fields={"future_config": {"enabled": True}}, + ) + request = RunRequest( + input="hello", + request_id="request-1", + context={"job_id": "job-1"}, + ) + assert config.metadata.name == "demo" + assert config.harness.settings["future_adapter_option"] is True + assert config.to_mapping()["future_config"] == {"enabled": True} + assert request.to_mapping()["context"] == {"job_id": "job-1"} -def call_json(*args: object) -> dict: - completed = subprocess.run( - [*COMMAND, *(str(arg) for arg in args)], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - if completed.returncode != 0: - raise AssertionError( - f"command failed: {completed.args}\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" - ) - return json.loads(completed.stdout) + client_mod._native = None + try: + FabricClient().plan(config) + except FabricNativeUnavailableError: + pass + else: + raise AssertionError("native-only FabricClient must reject a missing extension") if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/python/tests/smoke_sdk_concurrency.py b/python/tests/smoke_sdk_concurrency.py index 93bf20ac6..c158cc2a8 100644 --- a/python/tests/smoke_sdk_concurrency.py +++ b/python/tests/smoke_sdk_concurrency.py @@ -20,16 +20,13 @@ async def run_copy(client: FabricClient, fixture_agent: Path, root: Path, name: str) -> dict: agent = root / name copytree(fixture_agent, agent) - return await client.run(agent, profile="env_local", input_text=f"hello from {name}") + return await client.run(agent, profiles=["env_local"], input=f"hello from {name}") async def main() -> None: fixture_agent = ROOT / "tests" / "fixtures" / "hermes-shim-agent" - async with FabricClient( - command=("cargo", "run", "-q", "-p", "fabric-cli", "--"), - cwd=ROOT, - ) as client: + async with FabricClient() as client: with tempfile.TemporaryDirectory(prefix="fabric-sdk-concurrency-") as tmpdir: temp_root = Path(tmpdir) first, second = await asyncio.gather( diff --git a/python/tests/smoke_sdk_sessions.py b/python/tests/smoke_sdk_sessions.py index 13fc709fb..e5f762b03 100644 --- a/python/tests/smoke_sdk_sessions.py +++ b/python/tests/smoke_sdk_sessions.py @@ -13,23 +13,54 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from nemo_fabric import FabricClient, Session, SessionStatus +from nemo_fabric import ( + FabricCapabilityError, + FabricClient, + FabricStateError, + RunRequest, + RunResult, + Session, + SessionStatus, +) def _plan() -> dict[str, Any]: + config = { + "metadata": {"name": "demo"}, + "harness": {"adapter_id": "test.fabric.shim"}, + "runtime": { + "mode": "session", + "transport": "library", + "input_schema": "chat", + "output_schema": "message", + }, + } return { "agent_name": "demo", - "profile": "hermes_sdk", - "config": { - "runtime": { - "mode": "session", - "transport": "library", - "input_schema": "chat", - "output_schema": "message", - }, + "profiles": ["hermes_sdk"], + "effective_config": { + "agent_name": "demo", + "profiles": ["hermes_sdk"], + "agent_root": ".", + "config_path": "agent.yaml", + "config_root": ".", + "config": config, }, + "config": config, "adapter_descriptor": { - "descriptor": {"adapter_kind": "python", "adapter_id": "test.fabric.shim"} + "descriptor": { + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "harness": "hermes", + } + }, + "capabilities": { + "session": True, + "service": False, + "streaming": False, + "updates": False, + "cancellation": False, + "concurrent_invocations": False, }, } @@ -37,8 +68,9 @@ def _plan() -> dict[str, Any]: def _runtime() -> dict[str, Any]: return { "runtime_id": "runtime-1", + "runtime_binding": "fabric-runtime-binding-test", "agent_name": "demo", - "harness_type": "test.fabric.shim", + "harness": "hermes", "mode": "session", "adapter_kind": "python", "adapter_id": "test.fabric.shim", @@ -51,7 +83,7 @@ def _runtime() -> dict[str, Any]: } -class FakeNative: +class MockNative: def __init__(self) -> None: self.requests: list[dict[str, Any]] = [] self.stopped = 0 @@ -62,17 +94,38 @@ def invoke_runtime(self, plan_json: str, runtime_json: str, request_json: str) - turn = len(self.requests) return json.dumps( { - "status": "succeeded", + "agent_name": "demo", + "profiles": ["hermes_sdk"], + "harness": "hermes", + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "status": "failed" if request.get("input") == "fail" else "succeeded", "request_id": request["request_id"], "runtime_id": json.loads(runtime_json)["runtime_id"], "invocation_id": f"invocation-{turn}", - "events": [{"event_id": f"evt-{turn}", "kind": "log", "message": "ok"}], + "events": [ + { + "event_id": f"evt-{turn}", + "timestamp_millis": turn, + "kind": "log", + "message": "ok", + } + ], + "artifacts": {"artifacts": []}, "output": { "messages": [ {"role": "user", "content": request.get("input")}, {"role": "assistant", "content": f"reply-{turn}"}, ], }, + "error": { + "stage": "invoke", + "code": "adapter_failed", + "message": "adapter failed", + "retryable": False, + } + if request.get("input") == "fail" + else None, } ) @@ -82,31 +135,41 @@ def stop_runtime(self, plan_json: str, runtime_json: str) -> str: class NativeClient(FabricClient): - def __init__(self, native: FakeNative) -> None: + def __init__(self, native: MockNative) -> None: super().__init__() self.native = native - def _require_native_module(self, method: str) -> FakeNative: + def _require_native_module(self, method: str) -> MockNative: return self.native -def _session(native: FakeNative) -> Session: +def _session(native: MockNative) -> Session: return Session(client=NativeClient(native), plan=_plan(), runtime=_runtime()) async def stable_runtime_across_turns() -> None: - native = FakeNative() + native = MockNative() session = _session(native) assert session.status is SessionStatus.ACTIVE assert session.runtime_id == "runtime-1" assert session.session_id == "runtime-1" - assert "session_id" not in session.info + assert session.info["session_id"] == "runtime-1" assert not hasattr(session, "id") - await session.invoke("My name is Robin.") - await session.invoke("What's my name?") - + first = await session.invoke( + request=RunRequest( + input="My name is Robin.", + request_id="session-request-1", + context={"job_id": "job-1", "turn_id": "turn-1"}, + ), + ) + await session.invoke(input="What's my name?") + + assert isinstance(first, RunResult) + assert first.request_id == "session-request-1" assert [inv["runtime_id"] for inv in session.invocations] == ["runtime-1", "runtime-1"] + assert native.requests[0]["context"]["job_id"] == "job-1" + assert native.requests[0]["context"]["turn_id"] == "turn-1" assert native.requests[0]["context"]["session_id"] == "runtime-1" assert native.requests[1]["context"]["session_id"] == "runtime-1" assert "history" not in native.requests[0]["context"] @@ -115,42 +178,57 @@ async def stable_runtime_across_turns() -> None: async def stream_and_lifecycle() -> None: - native = FakeNative() + native = MockNative() session = _session(native) - items = [item async for item in session.stream("hello")] - assert items[-1]["status"] == "succeeded" - assert items[:-1] and all(e.get("kind") == "log" for e in items[:-1]) + items = [item async for item in session.stream(input="hello")] + assert items[-1].status == "succeeded" + assert items[:-1] and all(event.kind == "log" for event in items[:-1]) await session.stop() await session.stop() assert session.status is SessionStatus.STOPPED assert native.stopped == 1 try: - await session.invoke("too late") - except RuntimeError: + await session.invoke(input="too late") + except FabricStateError: pass else: raise AssertionError("invoke after stop should raise") -async def cancel_when_idle_marks_cancelled() -> None: - native = FakeNative() +async def unsupported_cancel_leaves_session_active() -> None: + native = MockNative() session = _session(native) - await session.cancel() - assert session.status is SessionStatus.CANCELLED - assert native.stopped == 1 try: - await session.invoke("after cancel") - except RuntimeError: + await session.cancel() + except FabricCapabilityError: pass else: - raise AssertionError("invoke after cancel should raise") + raise AssertionError("unsupported cancellation should raise") + assert session.status is SessionStatus.ACTIVE + await session.stop() + + +async def failed_result_exposes_structured_error() -> None: + native = MockNative() + session = _session(native) + result = await session.invoke(input="fail") + + assert isinstance(result, RunResult) + assert result.status == "failed" + assert result.error.stage == "invoke" + assert result.error.code == "adapter_failed" + assert result.error.retryable is False + await session.stop() + assert session.status is SessionStatus.STOPPED + assert native.stopped == 1 async def main() -> None: await stable_runtime_across_turns() await stream_and_lifecycle() - await cancel_when_idle_marks_cancelled() + await unsupported_cancel_leaves_session_active() + await failed_result_exposes_structured_error() print("smoke_sdk_sessions ok") diff --git a/python/tests/smoke_typed_config.py b/python/tests/smoke_typed_config.py index f304b7874..1c2d630e3 100644 --- a/python/tests/smoke_typed_config.py +++ b/python/tests/smoke_typed_config.py @@ -3,67 +3,82 @@ """Smoke test: typed (in-memory) config is first-class, with no agent directory. -WS4 guardrail. The SDK's ``*_config`` methods accept a typed config object and -resolve, diagnose, and run it without an on-disk agent package: +The unified SDK methods accept a typed config object and resolve, diagnose, and +run it without an on-disk agent package: -* ``plan_config`` / ``doctor_config`` resolve a maintained (repository) adapter +* ``plan`` / ``doctor`` resolve a maintained (repository) adapter with ``base_dir=None`` -- zero filesystem layout, no ``agent.yaml``. -* ``run_config`` drives a real core runtime run using only a local adapter directory +* ``run`` drives a real core runtime run using only a local adapter directory (still no agent package). -* the ``*_config`` methods are native-only; the CLI fallback raises a clear, - documented error rather than silently degrading. - -This complements ``smoke_native_sdk.py``, which exercises ``plan_config`` with a +This complements ``smoke_native_sdk.py``, which exercises ``plan`` with a ``base_dir`` pointed at an agent package. """ from __future__ import annotations import asyncio +import json +import subprocess import tempfile from pathlib import Path from shutil import copytree -from nemo_fabric import FabricClient, FabricNativeUnavailableError +import yaml + +from nemo_fabric import ( + FabricClient, + FabricConfig, + FabricProfileConfig, + RunRequest, + RunResult, +) ROOT = Path(__file__).resolve().parents[2] +COMMAND = ("cargo", "run", "-q", "-p", "fabric-cli", "--") # The test adapter (needs only python3, no secrets), shipped as a fixture. +SHIM_AGENT = ROOT / "tests" / "fixtures" / "hermes-shim-agent" SHIM_ADAPTERS = ROOT / "tests" / "fixtures" / "hermes-shim-agent" / "adapters" -def _repository_adapter_config() -> dict: +def _repository_adapter_config() -> FabricConfig: """Config referencing a maintained adapter resolvable without any package.""" - return { - "schema_version": "fabric.agent/v1alpha1", - "metadata": {"name": "typed-only-agent"}, - "harness": { - "adapter_id": "nvidia.fabric.hermes.sdk", - "resolution": "preinstalled", - }, - "models": { - "default": {"provider": "nvidia", "model": "test-model", "temperature": 0.0} - }, - "runtime": { - "mode": "oneshot", - "transport": "library", - "input_schema": "chat", - "output_schema": "message", - "artifacts": "./artifacts", - }, - "environment": { - "provider": "local", - "workspace": "./ws", - "artifacts": "./artifacts/local", - }, - "telemetry": {"enabled": False}, - } - - -def _shim_adapter_config() -> dict: + return FabricConfig.from_mapping( + { + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": "typed-only-agent"}, + "harness": { + "adapter_id": "nvidia.fabric.hermes.sdk", + "resolution": "preinstalled", + }, + "models": { + "default": { + "provider": "nvidia", + "model": "test-model", + "temperature": 0.0, + } + }, + "runtime": { + "mode": "oneshot", + "transport": "library", + "input_schema": "chat", + "output_schema": "message", + "artifacts": "./artifacts", + }, + "environment": { + "provider": "local", + "workspace": "./ws", + "artifacts": "./artifacts/local", + }, + "telemetry": {"enabled": False}, + } + ) + + +def _shim_adapter_config() -> FabricConfig: """Config referencing the test adapter (runs without secrets).""" - config = _repository_adapter_config() + config = _repository_adapter_config().to_mapping() config["metadata"] = {"name": "typed-only-run"} config["harness"] = { "adapter_id": "test.fabric.hermes_shim", @@ -73,24 +88,24 @@ def _shim_adapter_config() -> dict: config["models"] = { "default": {"provider": "test", "model": "test-model", "temperature": 0.0} } - return config + return FabricConfig.from_mapping(config) async def resolves_and_diagnoses_without_a_directory(client: FabricClient) -> None: - """plan_config / doctor_config resolve a maintained adapter with no package.""" + """plan / doctor resolve a maintained adapter with no package.""" config = _repository_adapter_config() # base_dir=None: the literal "no path at all" call must still resolve. - plan_no_path = client.plan_config(config) + plan_no_path = client.plan(config) assert plan_no_path["agent_name"] == "typed-only-agent" # Point base_dir at an EMPTY directory (not an agent package): resolution can # then only succeed via the baked-in repository adapter dir, so the source is # deterministic regardless of the process CWD. with tempfile.TemporaryDirectory(prefix="typed-no-dir-") as empty: - plan = client.plan_config(config, base_dir=empty) - report = await client.doctor_config(config, base_dir=empty) + plan = client.plan(config, base_dir=empty) + report = await client.doctor(config, base_dir=empty) descriptor = plan["adapter_descriptor"] assert descriptor["descriptor"]["adapter_id"] == "nvidia.fabric.hermes.sdk" @@ -98,12 +113,12 @@ async def resolves_and_diagnoses_without_a_directory(client: FabricClient) -> No assert descriptor["source"] == "repository", descriptor["source"] assert report["agent_name"] == "typed-only-agent" - assert report["checks"], "doctor_config produced no checks" + assert report.checks, "doctor produced no checks" assert report["status"] in {"pass", "warn", "fail"}, report["status"] async def runs_without_an_agent_package(client: FabricClient) -> None: - """run_config drives a core run with only an adapter dir (no agent.yaml).""" + """run drives a core run with only an adapter dir (no agent.yaml).""" config = _shim_adapter_config() with tempfile.TemporaryDirectory(prefix="typed-run-") as tmpdir: @@ -113,48 +128,83 @@ async def runs_without_an_agent_package(client: FabricClient) -> None: copytree(SHIM_ADAPTERS, base / "adapters") (base / "ws").mkdir() assert not (base / "agent.yaml").exists() - result = await client.run_config(config, base_dir=base, input_text="hello typed") - + result = await client.run( + config, + base_dir=base, + request=RunRequest( + input="hello typed", + request_id="typed-request-1", + context={"job_id": "job-1"}, + overrides={"max_iterations": 1}, + ), + ) + + assert isinstance(result, RunResult) assert result["status"] == "succeeded", result.get("status") + assert result.request_id == "typed-request-1" assert result["adapter_kind"] == "python" assert result["metadata"]["adapter_runner"] == "python" assert result["output"]["received"] == "hello typed" -async def typed_config_requires_native() -> None: - """The CLI fallback surfaces a clear error for every typed-config method.""" - - cli_client = FabricClient(command=("fabric",)) - config = _repository_adapter_config() - - # plan_config is sync; doctor_config / run_config are async. All three are - # native-only, so each must raise rather than silently degrade over the CLI. - try: - cli_client.plan_config(config) - except FabricNativeUnavailableError: - pass - else: - raise AssertionError("plan_config should require the native extension over the CLI path") - - for name, coro in ( - ("doctor_config", cli_client.doctor_config(config)), - ("run_config", cli_client.run_config(config)), - ): - try: - await coro - except FabricNativeUnavailableError: - pass - else: - raise AssertionError(f"{name} should require the native extension over the CLI path") +def sdk_and_cli_profile_stacks_match(client: FabricClient) -> None: + """The same config/profile stack plans identically through CLI and SDK.""" + + config = FabricConfig.from_mapping(_load_yaml(SHIM_AGENT / "agent.yaml")) + profiles = [ + FabricProfileConfig.from_mapping( + _load_yaml(SHIM_AGENT / "profiles" / "env-local.yaml") + ), + FabricProfileConfig.from_mapping( + _load_yaml(SHIM_AGENT / "profiles" / "mcp-github.yaml") + ), + ] + + sdk_plan = client.plan(config, profiles=profiles, base_dir=SHIM_AGENT) + cli_plan = _cli_plan(SHIM_AGENT, "env_local", "mcp_github") + + assert sdk_plan.profiles == tuple(cli_plan["profiles"]) == ("env_local", "mcp_github") + assert "profile" not in sdk_plan + assert "profile" not in cli_plan + sdk_mapping = sdk_plan.to_mapping() + assert sdk_mapping["config"] == cli_plan["config"] + assert sdk_mapping["effective_config"]["config"] == cli_plan["effective_config"]["config"] + assert sdk_mapping["adapter_descriptor"] == cli_plan["adapter_descriptor"] + assert sdk_mapping["capabilities"] == cli_plan["capabilities"] + assert sdk_mapping["capability_plan"] == cli_plan["capability_plan"] + assert sdk_mapping["environment_plan"] == cli_plan["environment_plan"] + assert sdk_mapping["telemetry_plan"] == cli_plan["telemetry_plan"] + assert sdk_mapping["resolution"] == cli_plan["resolution"] async def main() -> None: - await typed_config_requires_native() async with FabricClient() as client: + sdk_and_cli_profile_stacks_match(client) await resolves_and_diagnoses_without_a_directory(client) await runs_without_an_agent_package(client) print("smoke_typed_config ok") +def _load_yaml(path: Path) -> dict: + with path.open(encoding="utf-8") as stream: + return yaml.safe_load(stream) + + +def _cli_plan(agent: Path, *profiles: str) -> dict: + args = [*COMMAND, "plan", str(agent)] + for profile in profiles: + args.extend(["--profile", profile]) + completed = subprocess.run( + args, + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise AssertionError(completed.stderr) + return json.loads(completed.stdout) + + if __name__ == "__main__": asyncio.run(main()) diff --git a/schemas/adapter-descriptor.schema.json b/schemas/adapter-descriptor.schema.json index 082030ecd..a04c09589 100644 --- a/schemas/adapter-descriptor.schema.json +++ b/schemas/adapter-descriptor.schema.json @@ -1,6 +1,7 @@ { "$defs": { "AdapterConfigSupport": { + "additionalProperties": true, "description": "Adapter config support.", "properties": { "accepts": { @@ -46,6 +47,7 @@ ] }, "AdapterRequirements": { + "additionalProperties": true, "description": "Adapter runtime requirements.", "properties": { "binaries": { @@ -87,6 +89,7 @@ "type": "object" }, "AdapterTelemetrySupport": { + "additionalProperties": true, "description": "Adapter telemetry support.", "properties": { "supports": { @@ -101,10 +104,12 @@ } }, "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, "description": "Language-neutral adapter descriptor for a harness integration.", "properties": { "adapter_id": { "description": "Unique id for this adapter implementation.", + "minLength": 1, "type": "string" }, "adapter_kind": { @@ -116,6 +121,11 @@ "default": {}, "description": "Fabric config areas this adapter consumes or generates." }, + "harness": { + "description": "Stable machine-readable harness identifier implemented by this adapter.", + "minLength": 1, + "type": "string" + }, "requirements": { "$ref": "#/$defs/AdapterRequirements", "default": {}, @@ -134,6 +144,7 @@ }, "required": [ "adapter_id", + "harness", "adapter_kind" ], "title": "AdapterDescriptor", diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 8aa3f1d8a..d1979604c 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -234,13 +234,6 @@ "description": "Root used to resolve config-local paths.", "type": "string" }, - "profile": { - "description": "Selected profile when exactly one profile is applied.", - "type": [ - "string", - "null" - ] - }, "profiles": { "description": "Ordered selected profiles.", "items": { @@ -251,6 +244,7 @@ }, "required": [ "agent_name", + "profiles", "agent_root", "config_path", "config_root", @@ -259,6 +253,7 @@ "type": "object" }, "EnvironmentConfig": { + "additionalProperties": true, "description": "Execution environment configuration.", "properties": { "artifacts": { @@ -378,6 +373,7 @@ ] }, "FabricConfig": { + "additionalProperties": true, "description": "Versioned Fabric agent config.", "properties": { "environment": { @@ -464,6 +460,7 @@ "type": "object" }, "HarnessConfig": { + "additionalProperties": true, "description": "Harness selection.", "properties": { "adapter_id": { @@ -493,6 +490,7 @@ "type": "object" }, "McpConfig": { + "additionalProperties": true, "description": "MCP capability configuration.", "properties": { "servers": { @@ -521,6 +519,7 @@ ] }, "McpServerConfig": { + "additionalProperties": true, "description": "MCP server configuration.", "properties": { "exposure": { @@ -567,6 +566,7 @@ "type": "object" }, "MetadataConfig": { + "additionalProperties": true, "description": "Human-readable metadata.", "properties": { "description": { @@ -587,6 +587,7 @@ "type": "object" }, "ModelConfig": { + "additionalProperties": true, "description": "Model configuration.", "properties": { "api_key_env": { @@ -625,6 +626,7 @@ "type": "object" }, "ProfileRegistryConfig": { + "additionalProperties": true, "description": "Profile discovery config for curated package profiles.", "properties": { "directories": { @@ -703,6 +705,7 @@ "type": "object" }, "RuntimeConfig": { + "additionalProperties": true, "description": "Runtime mode and input/output contract.", "properties": { "artifacts": { @@ -713,6 +716,7 @@ ] }, "input_schema": { + "default": "text", "description": "Input schema label.", "type": "string" }, @@ -721,19 +725,18 @@ "description": "Runtime mode." }, "output_schema": { + "default": "text", "description": "Output schema label.", "type": "string" }, "transport": { "$ref": "#/$defs/Transport", + "default": "library", "description": "Transport used to operate the harness." } }, "required": [ - "mode", - "transport", - "input_schema", - "output_schema" + "mode" ], "type": "object" }, @@ -841,6 +844,7 @@ "type": "object" }, "SkillConfig": { + "additionalProperties": true, "description": "Skill capability configuration.", "properties": { "paths": { @@ -854,6 +858,7 @@ "type": "object" }, "TelemetryConfig": { + "additionalProperties": true, "description": "Telemetry configuration.", "properties": { "config": { diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index 7918b4b77..ac6bf9064 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -16,6 +16,7 @@ ] }, "EnvironmentConfig": { + "additionalProperties": true, "description": "Execution environment configuration.", "properties": { "artifacts": { @@ -83,6 +84,7 @@ ] }, "HarnessConfig": { + "additionalProperties": true, "description": "Harness selection.", "properties": { "adapter_id": { @@ -112,6 +114,7 @@ "type": "object" }, "McpConfig": { + "additionalProperties": true, "description": "MCP capability configuration.", "properties": { "servers": { @@ -140,6 +143,7 @@ ] }, "McpServerConfig": { + "additionalProperties": true, "description": "MCP server configuration.", "properties": { "exposure": { @@ -163,6 +167,7 @@ "type": "object" }, "MetadataConfig": { + "additionalProperties": true, "description": "Human-readable metadata.", "properties": { "description": { @@ -183,6 +188,7 @@ "type": "object" }, "ModelConfig": { + "additionalProperties": true, "description": "Model configuration.", "properties": { "api_key_env": { @@ -221,6 +227,7 @@ "type": "object" }, "ProfileRegistryConfig": { + "additionalProperties": true, "description": "Profile discovery config for curated package profiles.", "properties": { "directories": { @@ -274,6 +281,7 @@ ] }, "RuntimeConfig": { + "additionalProperties": true, "description": "Runtime mode and input/output contract.", "properties": { "artifacts": { @@ -284,6 +292,7 @@ ] }, "input_schema": { + "default": "text", "description": "Input schema label.", "type": "string" }, @@ -292,19 +301,18 @@ "description": "Runtime mode." }, "output_schema": { + "default": "text", "description": "Output schema label.", "type": "string" }, "transport": { "$ref": "#/$defs/Transport", + "default": "library", "description": "Transport used to operate the harness." } }, "required": [ - "mode", - "transport", - "input_schema", - "output_schema" + "mode" ], "type": "object" }, @@ -329,6 +337,7 @@ ] }, "SkillConfig": { + "additionalProperties": true, "description": "Skill capability configuration.", "properties": { "paths": { @@ -342,6 +351,7 @@ "type": "object" }, "TelemetryConfig": { + "additionalProperties": true, "description": "Telemetry configuration.", "properties": { "config": { @@ -403,6 +413,7 @@ } }, "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, "description": "Versioned Fabric agent config.", "properties": { "environment": { diff --git a/schemas/effective-config.schema.json b/schemas/effective-config.schema.json index ffe02a710..30f61dc69 100644 --- a/schemas/effective-config.schema.json +++ b/schemas/effective-config.schema.json @@ -16,6 +16,7 @@ ] }, "EnvironmentConfig": { + "additionalProperties": true, "description": "Execution environment configuration.", "properties": { "artifacts": { @@ -83,6 +84,7 @@ ] }, "FabricConfig": { + "additionalProperties": true, "description": "Versioned Fabric agent config.", "properties": { "environment": { @@ -169,6 +171,7 @@ "type": "object" }, "HarnessConfig": { + "additionalProperties": true, "description": "Harness selection.", "properties": { "adapter_id": { @@ -198,6 +201,7 @@ "type": "object" }, "McpConfig": { + "additionalProperties": true, "description": "MCP capability configuration.", "properties": { "servers": { @@ -226,6 +230,7 @@ ] }, "McpServerConfig": { + "additionalProperties": true, "description": "MCP server configuration.", "properties": { "exposure": { @@ -249,6 +254,7 @@ "type": "object" }, "MetadataConfig": { + "additionalProperties": true, "description": "Human-readable metadata.", "properties": { "description": { @@ -269,6 +275,7 @@ "type": "object" }, "ModelConfig": { + "additionalProperties": true, "description": "Model configuration.", "properties": { "api_key_env": { @@ -307,6 +314,7 @@ "type": "object" }, "ProfileRegistryConfig": { + "additionalProperties": true, "description": "Profile discovery config for curated package profiles.", "properties": { "directories": { @@ -360,6 +368,7 @@ ] }, "RuntimeConfig": { + "additionalProperties": true, "description": "Runtime mode and input/output contract.", "properties": { "artifacts": { @@ -370,6 +379,7 @@ ] }, "input_schema": { + "default": "text", "description": "Input schema label.", "type": "string" }, @@ -378,19 +388,18 @@ "description": "Runtime mode." }, "output_schema": { + "default": "text", "description": "Output schema label.", "type": "string" }, "transport": { "$ref": "#/$defs/Transport", + "default": "library", "description": "Transport used to operate the harness." } }, "required": [ - "mode", - "transport", - "input_schema", - "output_schema" + "mode" ], "type": "object" }, @@ -415,6 +424,7 @@ ] }, "SkillConfig": { + "additionalProperties": true, "description": "Skill capability configuration.", "properties": { "paths": { @@ -428,6 +438,7 @@ "type": "object" }, "TelemetryConfig": { + "additionalProperties": true, "description": "Telemetry configuration.", "properties": { "config": { @@ -511,13 +522,6 @@ "description": "Root used to resolve config-local paths.", "type": "string" }, - "profile": { - "description": "Selected profile when exactly one profile is applied.", - "type": [ - "string", - "null" - ] - }, "profiles": { "description": "Ordered selected profiles.", "items": { @@ -528,6 +532,7 @@ }, "required": [ "agent_name", + "profiles", "agent_root", "config_path", "config_root", diff --git a/schemas/profile.schema.json b/schemas/profile.schema.json index 1a98d2336..4e9629414 100644 --- a/schemas/profile.schema.json +++ b/schemas/profile.schema.json @@ -1,376 +1,6 @@ { - "$defs": { - "ControlLocation": { - "description": "Where Fabric control code runs relative to the environment.", - "oneOf": [ - { - "const": "external_control", - "description": "Fabric runs on the host/control plane and starts or connects to the harness in the environment.", - "type": "string" - }, - { - "const": "in_env_control", - "description": "Fabric runs inside the prepared environment with the harness.", - "type": "string" - } - ] - }, - "EnvironmentConfig": { - "description": "Execution environment configuration.", - "properties": { - "artifacts": { - "description": "Artifact path inside or outside the provider.", - "type": [ - "string", - "null" - ] - }, - "connection": { - "additionalProperties": true, - "description": "Provider connection metadata, such as server URL, session id, or namespace.", - "type": "object" - }, - "control_location": { - "$ref": "#/$defs/ControlLocation", - "default": "in_env_control", - "description": "Where Fabric control code runs relative to the environment." - }, - "metadata": { - "additionalProperties": true, - "description": "Consumer-provided environment metadata.", - "type": "object" - }, - "ownership": { - "$ref": "#/$defs/EnvironmentOwnership", - "default": "caller_owned", - "description": "Whether Fabric owns the environment resource." - }, - "provider": { - "description": "Environment provider, for example `local`, `docker`, `opensandbox`, or `k8s`.", - "type": "string" - }, - "settings": { - "additionalProperties": true, - "description": "Provider-specific settings.", - "type": "object" - }, - "workspace": { - "description": "Workspace path inside or outside the provider.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "provider" - ], - "type": "object" - }, - "EnvironmentOwnership": { - "description": "Whether Fabric owns the underlying environment resource.", - "oneOf": [ - { - "const": "caller_owned", - "description": "The caller or a surrounding system owns the environment resource.", - "type": "string" - }, - { - "const": "fabric_owned", - "description": "Fabric created or leased the environment resource and may release it.", - "type": "string" - } - ] - }, - "HarnessConfig": { - "description": "Harness selection.", - "properties": { - "adapter_id": { - "description": "Adapter implementation id.", - "type": "string" - }, - "resolution": { - "anyOf": [ - { - "$ref": "#/$defs/ResolutionStrategy" - }, - { - "type": "null" - } - ], - "description": "Selected install or availability strategy." - }, - "settings": { - "additionalProperties": true, - "description": "Harness-specific settings.", - "type": "object" - } - }, - "required": [ - "adapter_id" - ], - "type": "object" - }, - "McpConfig": { - "description": "MCP capability configuration.", - "properties": { - "servers": { - "additionalProperties": { - "$ref": "#/$defs/McpServerConfig" - }, - "description": "Named MCP servers.", - "type": "object" - } - }, - "type": "object" - }, - "McpExposure": { - "description": "MCP exposure strategy.", - "oneOf": [ - { - "const": "harness_native", - "description": "Map into harness-native MCP config through the selected adapter.", - "type": "string" - }, - { - "const": "fabric_managed", - "description": "Fabric manages MCP and exposes basic tools/actions.", - "type": "string" - } - ] - }, - "McpServerConfig": { - "description": "MCP server configuration.", - "properties": { - "exposure": { - "$ref": "#/$defs/McpExposure", - "description": "How Fabric exposes the MCP capability to the harness." - }, - "transport": { - "description": "MCP transport.", - "type": "string" - }, - "url": { - "description": "MCP server URL or process command, depending on transport.", - "type": "string" - } - }, - "required": [ - "transport", - "url", - "exposure" - ], - "type": "object" - }, - "ModelConfig": { - "description": "Model configuration.", - "properties": { - "api_key_env": { - "description": "Optional environment variable containing an API key.", - "type": [ - "string", - "null" - ] - }, - "model": { - "description": "Provider model identifier.", - "type": "string" - }, - "provider": { - "description": "Model provider name.", - "type": "string" - }, - "settings": { - "additionalProperties": true, - "description": "Provider-specific settings.", - "type": "object" - }, - "temperature": { - "description": "Optional temperature.", - "format": "double", - "type": [ - "number", - "null" - ] - } - }, - "required": [ - "provider", - "model" - ], - "type": "object" - }, - "ResolutionStrategy": { - "description": "Adapter install or availability strategy.", - "oneOf": [ - { - "const": "preinstalled", - "description": "Harness is already available in the prepared environment.", - "type": "string" - }, - { - "const": "image_provided", - "description": "Environment image already contains the harness and dependencies.", - "type": "string" - }, - { - "const": "pip_uv", - "description": "Adapter may install a Python package with pip or uv.", - "type": "string" - }, - { - "const": "npm", - "description": "Adapter may install a Node package.", - "type": "string" - }, - { - "const": "source", - "description": "Adapter may install from source.", - "type": "string" - }, - { - "const": "service", - "description": "Adapter connects to an already-running service.", - "type": "string" - }, - { - "const": "native_plugin", - "description": "Adapter is installed through a harness-native plugin manager.", - "type": "string" - } - ] - }, - "RuntimeConfig": { - "description": "Runtime mode and input/output contract.", - "properties": { - "artifacts": { - "description": "Artifact directory.", - "type": [ - "string", - "null" - ] - }, - "input_schema": { - "description": "Input schema label.", - "type": "string" - }, - "mode": { - "$ref": "#/$defs/RuntimeMode", - "description": "Runtime mode." - }, - "output_schema": { - "description": "Output schema label.", - "type": "string" - }, - "transport": { - "$ref": "#/$defs/Transport", - "description": "Transport used to operate the harness." - } - }, - "required": [ - "mode", - "transport", - "input_schema", - "output_schema" - ], - "type": "object" - }, - "RuntimeMode": { - "description": "Runtime lifecycle mode.", - "oneOf": [ - { - "const": "oneshot", - "description": "Request is the lifecycle boundary.", - "type": "string" - }, - { - "const": "service", - "description": "Long-running process or service is the lifecycle boundary.", - "type": "string" - }, - { - "const": "session", - "description": "Session is the lifecycle boundary.", - "type": "string" - } - ] - }, - "SkillConfig": { - "description": "Skill capability configuration.", - "properties": { - "paths": { - "description": "Skill paths relative to the agent root.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "TelemetryConfig": { - "description": "Telemetry configuration.", - "properties": { - "config": { - "description": "Pass-through telemetry backend config." - }, - "enabled": { - "default": false, - "description": "Whether telemetry is enabled for this run. Relay is the Phase 1 telemetry path.", - "type": "boolean" - }, - "mode": { - "description": "Telemetry mode, for example `sdk`, `gateway`, or `external`.", - "type": [ - "string", - "null" - ] - }, - "output_dir": { - "description": "Optional telemetry output directory.", - "type": [ - "string", - "null" - ] - }, - "project": { - "description": "Optional project name for telemetry backends.", - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "Transport": { - "description": "Runtime transport.", - "oneOf": [ - { - "const": "library", - "description": "In-process library/SDK call.", - "type": "string" - }, - { - "const": "cli", - "description": "CLI process.", - "type": "string" - }, - { - "const": "http", - "description": "HTTP service.", - "type": "string" - }, - { - "const": "native_plugin", - "description": "Harness-native plugin surface.", - "type": "string" - } - ] - } - }, "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Profile config applied on top of a Fabric config.", + "additionalProperties": true, "properties": { "description": { "description": "Optional profile description.", @@ -380,44 +10,36 @@ ] }, "environment": { - "anyOf": [ - { - "$ref": "#/$defs/EnvironmentConfig" - }, - { - "type": "null" - } - ], - "description": "Environment override." + "additionalProperties": true, + "description": "Partial environment overlay.", + "type": [ + "object", + "null" + ] }, "harness": { - "anyOf": [ - { - "$ref": "#/$defs/HarnessConfig" - }, - { - "type": "null" - } - ], - "description": "Harness overrides." + "additionalProperties": true, + "description": "Partial harness overlay.", + "type": [ + "object", + "null" + ] }, "mcp": { - "anyOf": [ - { - "$ref": "#/$defs/McpConfig" - }, - { - "type": "null" - } - ], - "description": "MCP capability override." + "additionalProperties": true, + "description": "Partial MCP overlay.", + "type": [ + "object", + "null" + ] }, "models": { - "additionalProperties": { - "$ref": "#/$defs/ModelConfig" - }, - "description": "Model aliases to add or replace.", - "type": "object" + "additionalProperties": true, + "description": "Partial model overlays by alias.", + "type": [ + "object", + "null" + ] }, "name": { "description": "Optional profile name used for directory discovery.", @@ -427,15 +49,12 @@ ] }, "runtime": { - "anyOf": [ - { - "$ref": "#/$defs/RuntimeConfig" - }, - { - "type": "null" - } - ], - "description": "Runtime override." + "additionalProperties": true, + "description": "Partial runtime overlay.", + "type": [ + "object", + "null" + ] }, "schema_version": { "description": "Optional profile schema version.", @@ -445,29 +64,23 @@ ] }, "skills": { - "anyOf": [ - { - "$ref": "#/$defs/SkillConfig" - }, - { - "type": "null" - } - ], - "description": "Skill capability override." + "additionalProperties": true, + "description": "Partial skill overlay.", + "type": [ + "object", + "null" + ] }, "telemetry": { - "anyOf": [ - { - "$ref": "#/$defs/TelemetryConfig" - }, - { - "type": "null" - } - ], - "description": "Telemetry override." + "additionalProperties": true, + "description": "Partial telemetry overlay.", + "type": [ + "object", + "null" + ] }, "tools": { - "description": "Tool capability override." + "description": "Tool capability overlay." } }, "title": "ProfileConfig", diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index fd2289970..9c44f13d7 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -1,6 +1,7 @@ { "$defs": { "AdapterConfigSupport": { + "additionalProperties": true, "description": "Adapter config support.", "properties": { "accepts": { @@ -21,10 +22,12 @@ "type": "object" }, "AdapterDescriptor": { + "additionalProperties": true, "description": "Language-neutral adapter descriptor for a harness integration.", "properties": { "adapter_id": { "description": "Unique id for this adapter implementation.", + "minLength": 1, "type": "string" }, "adapter_kind": { @@ -36,6 +39,11 @@ "default": {}, "description": "Fabric config areas this adapter consumes or generates." }, + "harness": { + "description": "Stable machine-readable harness identifier implemented by this adapter.", + "minLength": 1, + "type": "string" + }, "requirements": { "$ref": "#/$defs/AdapterRequirements", "default": {}, @@ -54,6 +62,7 @@ }, "required": [ "adapter_id", + "harness", "adapter_kind" ], "type": "object" @@ -99,6 +108,7 @@ ] }, "AdapterRequirements": { + "additionalProperties": true, "description": "Adapter runtime requirements.", "properties": { "binaries": { @@ -140,6 +150,7 @@ "type": "object" }, "AdapterTelemetrySupport": { + "additionalProperties": true, "description": "Adapter telemetry support.", "properties": { "supports": { @@ -336,13 +347,6 @@ "description": "Root used to resolve config-local paths.", "type": "string" }, - "profile": { - "description": "Selected profile when exactly one profile is applied.", - "type": [ - "string", - "null" - ] - }, "profiles": { "description": "Ordered selected profiles.", "items": { @@ -353,6 +357,7 @@ }, "required": [ "agent_name", + "profiles", "agent_root", "config_path", "config_root", @@ -361,6 +366,7 @@ "type": "object" }, "EnvironmentConfig": { + "additionalProperties": true, "description": "Execution environment configuration.", "properties": { "artifacts": { @@ -480,6 +486,7 @@ "type": "object" }, "FabricConfig": { + "additionalProperties": true, "description": "Versioned Fabric agent config.", "properties": { "environment": { @@ -566,6 +573,7 @@ "type": "object" }, "HarnessConfig": { + "additionalProperties": true, "description": "Harness selection.", "properties": { "adapter_id": { @@ -595,6 +603,7 @@ "type": "object" }, "McpConfig": { + "additionalProperties": true, "description": "MCP capability configuration.", "properties": { "servers": { @@ -623,6 +632,7 @@ ] }, "McpServerConfig": { + "additionalProperties": true, "description": "MCP server configuration.", "properties": { "exposure": { @@ -669,6 +679,7 @@ "type": "object" }, "MetadataConfig": { + "additionalProperties": true, "description": "Human-readable metadata.", "properties": { "description": { @@ -689,6 +700,7 @@ "type": "object" }, "ModelConfig": { + "additionalProperties": true, "description": "Model configuration.", "properties": { "api_key_env": { @@ -727,6 +739,7 @@ "type": "object" }, "ProfileRegistryConfig": { + "additionalProperties": true, "description": "Profile discovery config for curated package profiles.", "properties": { "directories": { @@ -807,7 +820,51 @@ ], "type": "object" }, + "RuntimeCapabilities": { + "description": "Lifecycle behavior implemented by a resolved runtime path.", + "properties": { + "cancellation": { + "description": "Whether an in-flight invocation can be cancelled.", + "type": "boolean" + }, + "concurrent_invocations": { + "description": "Whether the runtime accepts concurrent invocations.", + "type": "boolean" + }, + "metadata": { + "additionalProperties": true, + "description": "Additional adapter-specific capability metadata.", + "type": "object" + }, + "service": { + "description": "Whether the selected runtime supports service lifecycle operations.", + "type": "boolean" + }, + "session": { + "description": "Whether the selected runtime supports session lifecycle operations.", + "type": "boolean" + }, + "streaming": { + "description": "Whether invocations can emit progressive output.", + "type": "boolean" + }, + "updates": { + "description": "Whether a running runtime can accept config updates.", + "type": "boolean" + } + }, + "required": [ + "session", + "service", + "streaming", + "updates", + "cancellation", + "concurrent_invocations" + ], + "type": "object" + }, "RuntimeConfig": { + "additionalProperties": true, "description": "Runtime mode and input/output contract.", "properties": { "artifacts": { @@ -818,6 +875,7 @@ ] }, "input_schema": { + "default": "text", "description": "Input schema label.", "type": "string" }, @@ -826,19 +884,18 @@ "description": "Runtime mode." }, "output_schema": { + "default": "text", "description": "Output schema label.", "type": "string" }, "transport": { "$ref": "#/$defs/Transport", + "default": "library", "description": "Transport used to operate the harness." } }, "required": [ - "mode", - "transport", - "input_schema", - "output_schema" + "mode" ], "type": "object" }, @@ -863,6 +920,7 @@ ] }, "SkillConfig": { + "additionalProperties": true, "description": "Skill capability configuration.", "properties": { "paths": { @@ -876,6 +934,7 @@ "type": "object" }, "TelemetryConfig": { + "additionalProperties": true, "description": "Telemetry configuration.", "properties": { "config": { @@ -1002,6 +1061,10 @@ "description": "Root used to resolve agent package paths.", "type": "string" }, + "capabilities": { + "$ref": "#/$defs/RuntimeCapabilities", + "description": "Lifecycle behavior implemented by the selected runtime path." + }, "capability_plan": { "$ref": "#/$defs/CapabilityPlan", "default": { @@ -1045,13 +1108,6 @@ ], "description": "Resolved environment plan." }, - "profile": { - "description": "Selected profile when exactly one profile is applied.", - "type": [ - "string", - "null" - ] - }, "profiles": { "description": "Ordered selected profiles.", "items": { @@ -1085,6 +1141,8 @@ "required": [ "effective_config", "agent_name", + "profiles", + "capabilities", "agent_root", "config_path", "config_root", diff --git a/schemas/run-result.schema.json b/schemas/run-result.schema.json index 35860f7a7..ca1a2a3d9 100644 --- a/schemas/run-result.schema.json +++ b/schemas/run-result.schema.json @@ -268,8 +268,8 @@ }, "type": "array" }, - "harness_type": { - "description": "Harness type used for this run.", + "harness": { + "description": "Stable machine-readable harness identifier used for this run.", "type": "string" }, "invocation_id": { @@ -285,12 +285,12 @@ "default": null, "description": "Primary output." }, - "profile": { - "description": "Selected profile name when loaded through an agent manifest.", - "type": [ - "string", - "null" - ] + "profiles": { + "description": "Ordered profiles applied to this run.", + "items": { + "type": "string" + }, + "type": "array" }, "request_id": { "description": "Request id.", @@ -318,7 +318,8 @@ }, "required": [ "agent_name", - "harness_type", + "profiles", + "harness", "adapter_kind", "runtime_id", "invocation_id", diff --git a/schemas/runtime-handle.schema.json b/schemas/runtime-handle.schema.json index f1f405c20..0d69badaa 100644 --- a/schemas/runtime-handle.schema.json +++ b/schemas/runtime-handle.schema.json @@ -150,8 +150,8 @@ "$ref": "#/$defs/EnvironmentHandle", "description": "Prepared environment." }, - "harness_type": { - "description": "Harness type.", + "harness": { + "description": "Stable machine-readable harness identifier.", "type": "string" }, "mode": { @@ -171,7 +171,7 @@ "runtime_id", "runtime_binding", "agent_name", - "harness_type", + "harness", "mode", "adapter_kind", "environment" diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json index cb598415d..b8d2b1a5d 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json @@ -1,5 +1,6 @@ { "adapter_id": "test.fabric.hermes_shim", + "harness": "hermes", "adapter_kind": "python", "runner": { "module": "nemo_fabric_test_adapters.hermes_shim.adapter", diff --git a/tests/smoke_cli.py b/tests/smoke_cli.py index f7cb922fa..1437071e5 100644 --- a/tests/smoke_cli.py +++ b/tests/smoke_cli.py @@ -57,7 +57,7 @@ def main() -> None: direct_profile = temp_example / "profiles" / "hermes-sdk.yaml" direct_plan = call_json("plan", temp_example, "--profile", direct_profile) - assert direct_plan["profile"] == str(direct_profile) + assert direct_plan["profiles"] == [str(direct_profile)] assert direct_plan["adapter_descriptor"]["descriptor"]["adapter_id"] == "nvidia.fabric.hermes.sdk" profile_plans = [ @@ -136,7 +136,7 @@ def main() -> None: assert "interactive runtime session" in chat.stderr assert "agent: hermes-shim-agent" in chat.stderr assert "profile: env_local" in chat.stderr - assert "harness: test.fabric.hermes_shim" in chat.stderr + assert "harness: hermes" in chat.stderr assert "adapter: python" in chat.stderr assert chat.stderr.count("session_id: cli-session-123 (provided)") >= 2 assert "you[env_local:cli-session-123]> " in chat.stderr diff --git a/tests/smoke_hermes_session.py b/tests/smoke_hermes_session.py index f09ba1180..c5536bc1c 100644 --- a/tests/smoke_hermes_session.py +++ b/tests/smoke_hermes_session.py @@ -75,15 +75,18 @@ async def _run_sdk_session() -> None: from nemo_fabric import FabricClient, SessionStatus agent = str(ROOT / "examples" / "code-review-agent") - async with await FabricClient().start(agent, profile="hermes_session") as session: + async with await FabricClient().start_session( + agent, + profiles=["hermes_session"], + ) as session: assert session.status is SessionStatus.ACTIVE, session.status - r1 = await session.invoke("My name is Robin. Please remember it for later.") + r1 = await session.invoke(input="My name is Robin. Please remember it for later.") assert r1["status"] == "succeeded", r1 after_turn1 = session.messages assert len(after_turn1) >= 2, after_turn1 - r2 = await session.invoke("What is my name? Reply with just the name.") + r2 = await session.invoke(input="What is my name? Reply with just the name.") assert r2["status"] == "succeeded", r2 assert r2["runtime_id"] == r1["runtime_id"], (r1, r2) # Hermes should return a transcript that includes the prior turn. @@ -99,15 +102,18 @@ async def _run_cli_session() -> None: from nemo_fabric import FabricClient, SessionStatus agent = str(ROOT / "examples" / "code-review-agent") - async with await FabricClient().start(agent, profile="hermes_cli_session") as session: + async with await FabricClient().start_session( + agent, + profiles=["hermes_cli_session"], + ) as session: assert session.status is SessionStatus.ACTIVE, session.status - r1 = await session.invoke("My name is Robin. Please remember it for later.") + r1 = await session.invoke(input="My name is Robin. Please remember it for later.") assert r1["status"] == "succeeded", r1 assert r1["output"]["mode"] == "hermes_cli_session", r1 assert r1["output"]["session_id"] == session.session_id, r1 - r2 = await session.invoke("What is my name? Reply with just the name.") + r2 = await session.invoke(input="What is my name? Reply with just the name.") assert r2["status"] == "succeeded", r2 assert r2["runtime_id"] == r1["runtime_id"], (r1, r2) assert r2["output"]["session_id"] == r1["output"]["session_id"], (r1, r2) diff --git a/tests/test_hermes_cli.py b/tests/test_hermes_cli.py index 82cea45ab..067409ee7 100644 --- a/tests/test_hermes_cli.py +++ b/tests/test_hermes_cli.py @@ -15,9 +15,11 @@ async def test_hermes_cli_fields(hermes_command: Path, hermes_agent_dir: Path, hermes_cli_profile: str): # Ensure the hermes_cli adapter returns expected fields async with FabricClient() as client: - result = await client.run(hermes_agent_dir, - profile=hermes_cli_profile, - input_text="who are you?") + result = await client.run( + hermes_agent_dir, + profiles=[hermes_cli_profile], + input="who are you?", + ) assert result["status"] == "succeeded" assert result["adapter_kind"] == "process" @@ -48,11 +50,13 @@ async def test_hermes_cli_multi_turn(hermes_agent_dir: Path, hermes_cli_session_ This test calls the fake-hermes.py script rather than hermes itself, thus it doesn't require an API key, however the hermes_cli adapter does use the hermes_state module, so we can test that the session is recorded propperly. """ - async with await FabricClient().start(hermes_agent_dir, - profile=hermes_cli_session_profile) as session: + async with await FabricClient().start_session( + hermes_agent_dir, + profiles=[hermes_cli_session_profile], + ) as session: runtime_id = session.runtime["runtime_id"] - await session.invoke("prompt1") - await session.invoke("prompt2") + await session.invoke(input="prompt1") + await session.invoke(input="prompt2") session_db_path = hermes_agent_dir / "artifacts/hermes-home/state.db" assert session_db_path.exists(), f"Expected session DB at {session_db_path} does not exist" @@ -88,8 +92,8 @@ async def run_hermes_cli_relay( async with FabricClient() as client: self.result = await client.run( code_review_agent_dir, - profile="hermes_cli_relay", - input_text="Reply with exactly: relay ok", + profiles=["hermes_cli_relay"], + input="Reply with exactly: relay ok", ) self.output = self.result["output"] @@ -101,8 +105,10 @@ async def test_artifacts(self): assert self.result["status"] == "succeeded" assert self.result["adapter_kind"] == "process" assert self.result["metadata"]["adapter_runner"] == "process" - assert self.result["telemetry"]["relay_enabled"] is True - assert self.result["telemetry"]["metadata"]["relay_mode"] == "sdk" + assert len(self.result.telemetry) == 1 + assert self.result.telemetry[0].provider == "relay" + assert self.result.telemetry[0].metadata["relay_enabled"] is True + assert self.result.telemetry[0].metadata["relay_mode"] == "sdk" output = self.output assert output["adapter"] == "cli" @@ -148,7 +154,7 @@ async def test_artifacts(self): relay_config = json.loads(relay_config_path.read_text(encoding="utf-8")) assert relay_config["schema_version"] == "fabric.relay/v1alpha1" assert relay_config["relay"]["enabled"] is True - assert relay_config["fabric"]["profile"] == "hermes_cli_relay" + assert relay_config["fabric"]["profiles"] == ["hermes_cli_relay"] fabric_invocation_path = Path(output["fabric_invocation"]).resolve() assert fabric_invocation_path.is_file() diff --git a/tests/test_hermes_cli_preflight.py b/tests/test_hermes_cli_preflight.py index de47cbb55..769ada84b 100644 --- a/tests/test_hermes_cli_preflight.py +++ b/tests/test_hermes_cli_preflight.py @@ -28,9 +28,11 @@ async def test_preflight_api_key_e2e(hermes_agent_dir: Path, hermes_cli_profile: async with FabricClient() as client: - result = await client.run(hermes_agent_dir, - profile=hermes_cli_profile, - input_text="who are you?") + result = await client.run( + hermes_agent_dir, + profiles=[hermes_cli_profile], + input="who are you?", + ) if api_key_set: assert result["status"] == "succeeded" else: diff --git a/tests/test_sdk_contract.py b/tests/test_sdk_contract.py new file mode 100644 index 000000000..92703aba6 --- /dev/null +++ b/tests/test_sdk_contract.py @@ -0,0 +1,946 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the public Python SDK request/result contract.""" + +from __future__ import annotations + +import json +from inspect import signature +from typing import Any, get_overloads + +import pytest + +import nemo_fabric.errors as fabric_errors + +from nemo_fabric import ( + AdapterInfo, + DoctorReport, + EffectiveConfig, + EnvironmentConfig, + FabricClient, + FabricCapabilityError, + FabricConfig, + FabricConfigError, + FabricError, + FabricProfileConfig, + FabricNativeUnavailableError, + FabricRuntimeError, + FabricStateError, + HarnessConfig, + MetadataConfig, + RunPlan, + RunRequest, + RunResult, + RuntimeCapabilities, + RuntimeConfig, + RuntimeHandle, + RuntimeUpdate, + Session, + SessionInfo, +) + + +def test_public_contract_has_no_unreleased_aliases(): + assert list(signature(FabricClient).parameters) == [] + assert not hasattr(RunRequest, "from_text") + for name in ("plan_config", "run_config", "doctor_config", "start", "start_config"): + assert not hasattr(FabricClient, name) + + for name in ("resolve", "plan", "doctor", "run", "start_session", "start_service"): + assert len(get_overloads(getattr(FabricClient, name))) == 2, name + + assert not hasattr(fabric_errors, "FabricCliError") + + +def test_typed_config_validates_required_fields_and_preserves_extensions(): + raw = { + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": "demo", "owner": "sdk"}, + "harness": {"adapter_id": "test.fabric.shim", "future": True}, + "runtime": {"mode": "session"}, + "future_top_level": {"enabled": True}, + } + + config = FabricConfig.from_mapping(raw) + raw["metadata"]["name"] = "mutated" + + assert isinstance(config.metadata, MetadataConfig) + assert config.environment is None + assert config.metadata.name == "demo" + assert config.metadata.description is None + assert config.runtime.transport is None + assert "transport" not in config.runtime.to_mapping() + assert config.metadata.extra_fields == {"owner": "sdk"} + assert config.harness.extra_fields == {"future": True} + assert config.extra_fields == {"future_top_level": {"enabled": True}} + assert config.to_mapping()["future_top_level"] == {"enabled": True} + assert "models" not in config.to_mapping() + + runtime = RuntimeConfig(mode="service") + config.runtime = runtime + config["future_runtime"] = {"enabled": True} + assert isinstance(config.runtime, RuntimeConfig) + assert config.extra_fields["future_runtime"] == {"enabled": True} + + with pytest.raises(TypeError): + FabricConfig( # type: ignore[call-arg] + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + unexpected=True, + ) + with pytest.raises(FabricConfigError, match="metadata"): + FabricConfig.from_mapping({"harness": {"adapter_id": "test.fabric.shim"}}) + with pytest.raises(FabricConfigError, match="adapter_id"): + HarnessConfig(adapter_id="") + with pytest.raises(FabricConfigError, match="runtime mode"): + RuntimeConfig(mode="invalid") + with pytest.raises(FabricConfigError, match="harness settings"): + HarnessConfig( + adapter_id="test.fabric.shim", + settings=[], # type: ignore[arg-type] + ) + with pytest.raises(FabricConfigError, match="extra_fields"): + MetadataConfig(name="demo", extra_fields=[]) # type: ignore[arg-type] + with pytest.raises(FabricConfigError, match="environment settings"): + EnvironmentConfig(settings=[]) # type: ignore[arg-type] + with pytest.raises(FabricConfigError, match="runtime must be"): + FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + runtime=[], # type: ignore[arg-type] + ) + with pytest.raises(FabricConfigError, match="models"): + FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + models=[], # type: ignore[arg-type] + ) + + +def test_typed_profile_preserves_partial_overlay_sections(): + profile = FabricProfileConfig.from_mapping( + { + "name": "session", + "harness": {"settings": {"timeout_seconds": 30}}, + "runtime": {"mode": "session"}, + } + ) + + assert profile.to_mapping()["harness"] == { + "settings": {"timeout_seconds": 30} + } + assert profile.to_mapping()["runtime"] == {"mode": "session"} + + +def test_inspection_models_are_typed_read_only_mappings(): + plan = RunPlan.from_mapping( + { + "agent_name": "demo", + "profiles": ["runtime", "telemetry"], + "effective_config": { + "agent_name": "demo", + "profiles": ["runtime", "telemetry"], + "agent_root": ".", + "config_path": "agent.yaml", + "config_root": ".", + "config": { + "metadata": {"name": "demo"}, + "harness": {"adapter_id": "test.fabric.shim"}, + "runtime": {"mode": "session"}, + }, + }, + "adapter_descriptor": { + "descriptor": { + "adapter_id": "test.fabric.shim", + "harness": "hermes", + "adapter_kind": "python", + "future": "value", + } + }, + "capabilities": { + "session": True, + "service": False, + "streaming": False, + "updates": False, + "cancellation": False, + "concurrent_invocations": False, + "future_capability": "declared", + }, + } + ) + + assert isinstance(plan.effective_config, EffectiveConfig) + assert isinstance(plan.adapter, AdapterInfo) + assert isinstance(plan.capabilities, RuntimeCapabilities) + assert plan.profiles == ("runtime", "telemetry") + assert plan.adapter.harness == "hermes" + assert "harness_type" not in plan.adapter + assert plan.adapter.extra_fields["future"] == "value" + assert plan.capabilities.extra_fields["future_capability"] == "declared" + resolved = plan.to_mapping() + plan.effective_config.config.metadata.name = "mutated" + assert plan.to_mapping() == resolved + with pytest.raises(TypeError): + plan["agent_name"] = "mutated" # type: ignore[index] + + +def test_runtime_handle_distinguishes_contract_and_extension_fields(): + handle = RuntimeHandle.from_mapping( + { + "runtime_id": "runtime-1", + "runtime_binding": "binding-1", + "agent_name": "demo", + "harness": "hermes", + "mode": "session", + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "environment": { + "environment_id": "environment-1", + "provider": "local", + "control_location": "external_control", + "ownership": "caller_owned", + }, + "future_handle_field": "value", + } + ) + + assert handle.extra_fields == {"future_handle_field": "value"} + + +@pytest.mark.parametrize( + "field", + ( + "runtime_id", + "runtime_binding", + "agent_name", + "harness", + "mode", + "adapter_kind", + "environment", + ), +) +def test_runtime_handle_requires_native_contract_fields(field): + raw = _runtime() + del raw[field] + + with pytest.raises(FabricConfigError, match=field.replace("_", " ")): + RuntimeHandle.from_mapping(raw) + + +@pytest.mark.parametrize( + ("model", "payload"), + ( + (EffectiveConfig, {"config": {}}), + (DoctorReport, {}), + (RunResult, {}), + (SessionInfo, {}), + ), +) +def test_snapshot_models_require_profiles(model, payload): + with pytest.raises(FabricConfigError, match="profiles is required"): + model.from_mapping(payload) + + +def test_run_plan_requires_profiles(): + raw = _plan() + del raw["profiles"] + + with pytest.raises(FabricConfigError, match="RunPlan profiles is required"): + RunPlan.from_mapping(raw) + + +def test_runtime_capabilities_reject_non_boolean_values(): + with pytest.raises(FabricConfigError, match="session capability"): + RuntimeCapabilities.from_mapping({"session": "false"}) + + +def test_doctor_report_and_errors_expose_typed_contract_fields(): + report = DoctorReport.from_mapping( + { + "agent_name": "demo", + "profiles": [], + "status": "warn", + "checks": [ + { + "name": "runtime.mode", + "status": "warn", + "message": "not implemented", + } + ], + } + ) + error = FabricRuntimeError( + "invoke failed", + stage="invoke", + code="adapter_failed", + retryable=True, + details={"adapter_id": "test.fabric.shim"}, + ) + + assert report.checks[0].name == "runtime.mode" + assert error.stage == "invoke" + assert error.code == "adapter_failed" + assert error.retryable is True + assert error.details == {"adapter_id": "test.fabric.shim"} + + +def _plan() -> dict[str, Any]: + config = { + "metadata": {"name": "demo"}, + "harness": {"adapter_id": "test.fabric.shim"}, + "runtime": { + "mode": "session", + "transport": "library", + "input_schema": "chat", + "output_schema": "message", + }, + } + return { + "agent_name": "demo", + "profiles": ["typed"], + "effective_config": { + "agent_name": "demo", + "profiles": ["typed"], + "agent_root": ".", + "config_path": "agent.yaml", + "config_root": ".", + "config": config, + }, + "config": config, + "adapter_descriptor": { + "descriptor": { + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "harness": "hermes", + } + }, + "capabilities": { + "session": True, + "service": False, + "streaming": False, + "updates": False, + "cancellation": False, + "concurrent_invocations": False, + }, + } + + +def _runtime() -> dict[str, Any]: + return { + "runtime_id": "runtime-1", + "runtime_binding": "fabric-runtime-binding-test", + "agent_name": "demo", + "harness": "hermes", + "mode": "session", + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "environment": { + "environment_id": "environment-1", + "provider": "local", + "control_location": "external_control", + "ownership": "caller_owned", + }, + } + + +def _run_result(**updates: Any) -> dict[str, Any]: + result = { + "agent_name": "demo", + "profiles": [], + "harness": "hermes", + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "runtime_id": "runtime-1", + "invocation_id": "invocation-1", + "request_id": "request-1", + "status": "succeeded", + "output": None, + "artifacts": {"artifacts": []}, + "events": [], + } + result.update(updates) + return result + + +def _fabric_config() -> FabricConfig: + return FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + runtime=RuntimeConfig(mode="session"), + ) + + +class NativeRecorder: + def __init__(self) -> None: + self.requests: list[dict[str, Any]] = [] + self.path_profile_calls: list[Any] = [] + self.stopped = 0 + self.fail_invoke = False + + def plan(self, path: str, profile: Any = None) -> str: + assert path == "agent" + self.path_profile_calls.append(profile) + return json.dumps(_plan()) + + def inspect(self, path: str, profile: Any = None) -> str: + assert path == "agent" + self.path_profile_calls.append(profile) + return json.dumps(_plan()["effective_config"]) + + def resolve_config( + self, + config_json: str, + profiles_json: str | None = None, + base_dir: str | None = None, + ) -> str: + assert json.loads(config_json)["metadata"]["name"] == "demo" + return json.dumps(_plan()["effective_config"]) + + def plan_config( + self, + config_json: str, + profiles_json: str | None = None, + base_dir: str | None = None, + ) -> str: + assert json.loads(config_json)["metadata"]["name"] == "demo" + return json.dumps(_plan()) + + def start_runtime(self, plan_json: str) -> str: + assert json.loads(plan_json)["agent_name"] == "demo" + return json.dumps(_runtime()) + + def invoke_runtime( + self, plan_json: str, runtime_json: str, request_json: str + ) -> str: + if self.fail_invoke: + raise RuntimeError("native invoke failed") + request = json.loads(request_json) + self.requests.append(request) + return json.dumps( + { + "agent_name": "demo", + "profiles": ["typed"], + "harness": "hermes", + "adapter_kind": "python", + "adapter_id": "test.fabric.shim", + "runtime_id": json.loads(runtime_json)["runtime_id"], + "invocation_id": "invocation-1", + "request_id": request["request_id"], + "status": "failed" if request["input"] == "fail" else "succeeded", + "output": {"received": request["input"]}, + "error": { + "stage": "invoke", + "code": "adapter_failed", + "message": "adapter failed", + "retryable": False, + } + if request["input"] == "fail" + else None, + "artifacts": {"artifacts": []}, + "events": [ + { + "event_id": "event-1", + "timestamp_millis": 1, + "kind": "invocation_end", + "message": "completed", + } + ], + } + ) + + def stop_runtime(self, plan_json: str, runtime_json: str) -> str: + self.stopped += 1 + return json.dumps([]) + + +class NativeClient(FabricClient): + def __init__(self, native: NativeRecorder) -> None: + super().__init__() + self.native = native + + def _native_module(self) -> NativeRecorder: + return self.native + + def _require_native_module(self, method: str) -> NativeRecorder: + return self.native + + +def test_run_request_is_mapping_compatible_and_json_safe(): + context = {"run_id": "run-1", "labels": ["sdk"]} + overrides = {"temperature": 0, "limits": {"turns": 1}} + request = RunRequest( + input={"messages": [{"role": "user", "content": "hello"}]}, + request_id="request-1", + context=context, + overrides=overrides, + ) + context["labels"].append("mutated") + overrides["limits"]["turns"] = 2 + + assert request["request_id"] == "request-1" + assert request.request_id == "request-1" + assert request.to_mapping()["input"] == { + "messages": [{"role": "user", "content": "hello"}] + } + assert request.to_mapping()["context"] == {"run_id": "run-1", "labels": ["sdk"]} + assert request.to_mapping()["overrides"] == { + "temperature": 0, + "limits": {"turns": 1}, + } + + copied = request.to_dict() + copied["context"]["run_id"] = "changed" + assert request.to_mapping()["context"] == {"run_id": "run-1", "labels": ["sdk"]} + + +def test_run_request_from_mapping_copies_and_validates_context(): + raw = { + "input": "hello", + "request_id": "request-1", + "context": {"job_id": "job-1"}, + } + + request = RunRequest.from_mapping(raw) + raw["context"]["job_id"] = "mutated" + + assert request.input == "hello" + assert request.context == {"job_id": "job-1"} + + with pytest.raises(FabricConfigError, match="request context"): + RunRequest.from_mapping({"input": "bad", "context": "not-a-mapping"}) + + +def test_run_request_constructor_validates_context_and_overrides(): + with pytest.raises(FabricConfigError, match="request context"): + RunRequest(input="bad", context="not-a-mapping") # type: ignore[arg-type] + + with pytest.raises(FabricConfigError, match="request overrides"): + RunRequest(input="bad", overrides="not-a-mapping") # type: ignore[arg-type] + + with pytest.raises(FabricConfigError, match="request context"): + RunRequest(input="bad", context=[]) # type: ignore[arg-type] + + with pytest.raises(FabricConfigError, match="request extra_fields"): + RunRequest(input="bad", extra_fields=[]) # type: ignore[arg-type] + + with pytest.raises(FabricConfigError, match="finite"): + RunRequest(input=float("nan")) + + +def test_run_request_constructor_generates_request_metadata(): + request = RunRequest(input="hello") + + assert request.input == "hello" + assert request.request_id.startswith("request-") + assert request.context == {} + + +def test_run_result_wraps_nested_error_and_keeps_mapping_access(): + result = RunResult.from_mapping( + _run_result( + status="failed", + output={}, + error={ + "stage": "invoke", + "code": "adapter_failed", + "message": "adapter failed", + "retryable": False, + }, + events=[{"kind": "log", "message": "hello"}], + ) + ) + + assert result["status"] == "failed" + assert result.status == "failed" + assert result.error.code == "adapter_failed" + assert result.error["stage"] == "invoke" + assert result.artifacts.artifacts == () + assert result.events[0].kind == "log" + assert result.to_dict()["error"]["code"] == "adapter_failed" + + +def test_run_result_exposes_detached_json_values(): + result = RunResult.from_mapping( + _run_result( + output={"plugins": ["observability/nemo_relay"]}, + metadata={"labels": ["sdk"]}, + future={"values": [1]}, + ) + ) + + output = result.output + metadata = result.metadata + future = result.extra_fields["future"] + + assert output["plugins"] == ["observability/nemo_relay"] + assert metadata["labels"] == ["sdk"] + assert future["values"] == [1] + + output["plugins"].append("mutated") + metadata["labels"].append("mutated") + future["values"].append(2) + + assert result.output == {"plugins": ["observability/nemo_relay"]} + assert result.metadata == {"labels": ["sdk"]} + assert result.extra_fields["future"] == {"values": [1]} + + +def test_run_result_normalizes_core_telemetry_reference(): + result = RunResult.from_mapping( + _run_result( + telemetry={ + "relay_enabled": True, + "metadata": { + "relay_output_dir": "/tmp/relay", + "trace_id": "trace-1", + }, + }, + ) + ) + + assert result.telemetry[0].provider == "relay" + assert result.telemetry[0].kind == "trace" + assert result.telemetry[0].uri == "/tmp/relay" + assert result.telemetry[0].trace_id == "trace-1" + + +@pytest.mark.parametrize( + "field", + ( + "agent_name", + "harness", + "adapter_kind", + "runtime_id", + "invocation_id", + "request_id", + "status", + ), +) +def test_run_result_requires_schema_identity_fields(field): + raw = _run_result() + del raw[field] + + with pytest.raises(FabricConfigError, match=field.replace("_", " ")): + RunResult.from_mapping(raw) + + +async def test_run_accepts_full_run_request_on_native_path(): + native = NativeRecorder() + client = NativeClient(native) + + with pytest.raises(FabricConfigError, match="complete request"): + await client.run( + "agent", + request=RunRequest(input="hello"), + context={"turn_id": "turn-4"}, + ) + + result = await client.run( + "agent", + request=RunRequest( + input="hello", + request_id="request-4", + context={"job_id": "job-4"}, + overrides={"request": True}, + ), + ) + + assert isinstance(result, RunResult) + assert result.status == "succeeded" + assert native.requests[0] == { + "input": "hello", + "request_id": "request-4", + "context": {"job_id": "job-4"}, + "overrides": {"request": True}, + } + + +async def test_typed_source_accepts_granular_request_fields_and_returns_result(): + native = NativeRecorder() + client = NativeClient(native) + result = await client.run( + _fabric_config(), + input="hello", + request_id="request-1", + context={"job_id": "job-1"}, + overrides={"max_iterations": 1}, + ) + + assert isinstance(result, RunResult) + assert result.status == "succeeded" + assert result["request_id"] == "request-1" + assert native.requests[0] == { + "input": "hello", + "request_id": "request-1", + "context": {"job_id": "job-1"}, + "overrides": {"max_iterations": 1}, + } + + +async def test_invalid_request_context_raises_config_error(): + native = NativeRecorder() + client = NativeClient(native) + + with pytest.raises(FabricConfigError, match="request context"): + await client.run( + _fabric_config(), + input="hello", + context="not-a-mapping", # type: ignore[arg-type] + ) + + assert native.requests == [] + + +async def test_native_runtime_errors_use_typed_exception_and_stop_runtime(): + native = NativeRecorder() + native.fail_invoke = True + client = NativeClient(native) + + with pytest.raises(FabricRuntimeError, match="native invoke failed") as error: + await client.run(_fabric_config(), input="hello") + + assert isinstance(error.value, FabricError) + assert isinstance(error.value.__cause__, RuntimeError) + assert native.stopped == 1 + + +async def test_start_service_reports_capability_failure_contract(): + client = NativeClient(NativeRecorder()) + + with pytest.raises(FabricCapabilityError) as caught: + await client.start_service("agent", service_id="service-1") + + assert caught.value.stage == "start" + assert caught.value.code == "service_not_supported" + assert caught.value.details == {"service": False, "service_id": "service-1"} + + +async def test_start_service_validates_overrides_before_planning(): + native = NativeRecorder() + client = NativeClient(native) + + with pytest.raises(FabricConfigError, match="service overrides"): + await client.start_service("agent", overrides=[]) # type: ignore[arg-type] + + assert native.path_profile_calls == [] + + +def test_public_sdk_exceptions_share_a_common_base(): + assert issubclass(FabricConfigError, FabricError) + assert issubclass(FabricRuntimeError, FabricError) + assert issubclass(FabricStateError, FabricError) + assert issubclass(FabricCapabilityError, FabricError) + assert issubclass(FabricNativeUnavailableError, FabricError) + + +async def test_session_invoke_accepts_run_request_and_turn_fields(): + native = NativeRecorder() + session = Session( + client=NativeClient(native), + plan=_plan(), + runtime=_runtime(), + overrides={"session": True, "limits": {"session": 1}}, + session_id="session-1", + ) + + result = await session.invoke( + request=RunRequest( + input="hello", + request_id="request-2", + context={"job_id": "job-2"}, + overrides={"request": True, "limits": {"request": 1}}, + ), + ) + + assert isinstance(result, RunResult) + assert result.request_id == "request-2" + assert native.requests[0] == { + "input": "hello", + "request_id": "request-2", + "context": { + "job_id": "job-2", + "session_id": "session-1", + }, + "overrides": { + "session": True, + "request": True, + "limits": {"session": 1, "request": 1}, + }, + } + + with pytest.raises(FabricConfigError, match="complete request"): + await session.invoke( + request=RunRequest(input="hello"), + context={"turn_id": "turn-1"}, + ) + + +async def test_session_info_stream_and_capability_errors_are_typed(): + session = Session( + client=NativeClient(NativeRecorder()), + plan=RunPlan.from_mapping(_plan()), + runtime=_runtime(), + session_id="session-1", + ) + + assert isinstance(session.info, SessionInfo) + assert session.info.profiles == ("typed",) + assert session.info.harness == "hermes" + assert session.info.adapter_id == "test.fabric.shim" + + streamed = [item async for item in session.stream(input="hello")] + assert streamed[0].kind == "invocation_end" + assert isinstance(streamed[-1], RunResult) + + with pytest.raises(FabricCapabilityError, match="cancellation"): + await session.cancel() + assert session.info.status == "active" + + with pytest.raises(FabricCapabilityError, match="updates"): + await session.update(RuntimeUpdate.from_mapping({"overrides": {"x": 1}})) + + +async def test_run_rejects_multiple_primary_input_sources(): + client = NativeClient(NativeRecorder()) + + with pytest.raises(FabricConfigError, match="at most one input source"): + await client.run( + _fabric_config(), + input="hello", + request={"input": "request"}, + ) + + +async def test_unified_agent_source_dispatches_fabric_config_to_runtime_path(): + native = NativeRecorder() + client = NativeClient(native) + + result = await client.run( + _fabric_config(), + input="hello", + request_id="request-5", + ) + + assert result.request_id == "request-5" + assert native.requests[0]["input"] == "hello" + + +async def test_lifecycle_methods_reject_raw_mapping_agent_source(): + native = NativeRecorder() + client = NativeClient(native) + + with pytest.raises(FabricConfigError, match="FabricConfig.from_mapping"): + await client.run({"metadata": {"name": "demo"}}, input="hello") + + assert native.requests == [] + + +def test_config_methods_reject_raw_mappings_and_pydantic_like_objects(): + class ModelDumpLike: + def model_dump(self, *, mode: str, exclude_none: bool) -> dict[str, Any]: + return {"metadata": {"name": "demo"}} + + client = NativeClient(NativeRecorder()) + + with pytest.raises(FabricConfigError, match="FabricConfig.from_mapping"): + client.plan({"metadata": {"name": "demo"}}) + + with pytest.raises(FabricConfigError, match="FabricConfig"): + client.plan(ModelDumpLike()) + + +def test_profile_configs_require_explicit_profile_config_conversion(): + client = NativeClient(NativeRecorder()) + + with pytest.raises(FabricConfigError, match="FabricProfileConfig values"): + client.plan(_fabric_config(), profiles="typed_relay") # type: ignore[arg-type] + + with pytest.raises(FabricConfigError, match="FabricProfileConfig.from_mapping"): + client.plan( + _fabric_config(), + profiles=[{"name": "typed_relay"}], + ) + + +def test_path_source_accepts_single_profile_name(): + native = NativeRecorder() + + NativeClient(native).plan("agent", profiles="hermes_session") + + assert native.path_profile_calls == [["hermes_session"]] + + +def test_path_source_rejects_mapping_profiles_before_native_planning(): + native = NativeRecorder() + + with pytest.raises(FabricConfigError, match="profile names"): + NativeClient(native).plan( + "agent", + profiles={"name": "hermes_session"}, # type: ignore[arg-type] + ) + + assert native.path_profile_calls == [] + + +def test_fabric_config_constructors_emit_schema_shaped_mappings(): + config = FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig( + adapter_id="test.fabric.shim", + resolution="preinstalled", + settings={"workspace": "./ws"}, + ), + runtime=RuntimeConfig( + mode="oneshot", + transport="cli", + input_schema="chat", + output_schema="message", + ), + ) + copied = config.to_mapping() + copied["harness"]["settings"]["workspace"] = "mutated" + + assert config["schema_version"] == "fabric.agent/v1alpha1" + assert config["metadata"] == {"name": "demo"} + assert config["harness"]["adapter_id"] == "test.fabric.shim" + assert config["runtime"]["mode"] == "oneshot" + assert config["harness"]["settings"]["workspace"] == "./ws" + + profile = FabricProfileConfig.from_mapping({"name": "typed_relay"}) + assert profile.to_mapping() == { + "schema_version": "fabric.profile/v1alpha1", + "name": "typed_relay", + } + + +def test_resolve_accepts_path_and_fabric_config_sources(): + client = NativeClient(NativeRecorder()) + + path_config = client.resolve("agent") + typed_config = client.resolve(_fabric_config()) + + assert path_config["config"]["runtime"]["mode"] == "session" + assert typed_config["config"]["runtime"]["mode"] == "session" + + +async def test_start_session_alias_returns_session_and_info_includes_session_id(): + session = await NativeClient(NativeRecorder()).start_session( + "agent", + session_id="session-1", + ) + + assert session.session_id == "session-1" + assert session.info["session_id"] == "session-1" + + +async def test_session_state_errors_use_sdk_error_hierarchy(): + session = Session( + client=NativeClient(NativeRecorder()), + plan=_plan(), + runtime=_runtime(), + ) + await session.stop() + + with pytest.raises(FabricStateError, match="cannot invoke a stopped session"): + await session.invoke(input="hello") diff --git a/tests/test_session.py b/tests/test_session.py index 1bbf5c7f2..795be069c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,52 +1,79 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for the SDK Session boundary: start / invoke / stream / cancel / stop. - -Dependency-free: a fake native lifecycle module stands in for the Rust binding, -so these exercise the Python orchestration without Hermes or a built extension. -""" +"""Behavior tests for the public Session lifecycle.""" from __future__ import annotations import asyncio import json -import time +import threading from typing import Any +from unittest.mock import MagicMock import pytest -from nemo_fabric import FabricClient, FabricNativeUnavailableError, Session, SessionStatus +from nemo_fabric import ( + FabricCapabilityError, + FabricClient, + FabricConfig, + FabricConfigError, + FabricNativeUnavailableError, + FabricRuntimeError, + FabricStateError, + HarnessConfig, + MetadataConfig, + RunResult, + RuntimeConfig, + Session, + SessionStatus, +) from nemo_fabric import client as client_mod +from nemo_fabric import session as session_mod -def _plan(adapter_kind: str = "python", runtime_mode: str = "session") -> dict[str, Any]: +def _plan(runtime_mode: str = "session") -> dict[str, Any]: + config = { + "metadata": {"name": "demo"}, + "harness": {"adapter_id": "test.fabric.shim"}, + "runtime": {"mode": runtime_mode, "transport": "library"}, + } return { "agent_name": "demo", - "profile": "hermes_sdk", - "config": { - "runtime": { - "mode": runtime_mode, - "transport": "library", - "input_schema": "chat", - "output_schema": "message", - }, + "profiles": ["typed"], + "effective_config": { + "agent_name": "demo", + "profiles": ["typed"], + "agent_root": ".", + "config_path": "agent.yaml", + "config_root": ".", + "config": config, }, + "config": config, "adapter_descriptor": { "descriptor": { - "adapter_kind": adapter_kind, "adapter_id": "test.fabric.shim", - "runner": {"module": "fake.module", "callable": "run"}, + "harness": "hermes", + "adapter_kind": "python", } }, + "capabilities": { + "session": runtime_mode == "session", + "service": False, + "streaming": False, + "updates": False, + "cancellation": False, + "concurrent_invocations": False, + }, } def _runtime() -> dict[str, Any]: return { "runtime_id": "runtime-1", + "runtime_binding": "fabric-runtime-binding-test", "agent_name": "demo", - "harness_type": "test.fabric.shim", + "harness": "hermes", "mode": "session", "adapter_kind": "python", "adapter_id": "test.fabric.shim", @@ -59,559 +86,372 @@ def _runtime() -> dict[str, Any]: } -class FakeNative: - def __init__(self, runtime_mode: str = "session") -> None: - self.runtime_mode = runtime_mode - self.plans: list[dict[str, Any]] = [] - self.requests: list[dict[str, Any]] = [] - self.stopped = 0 - self.block_invoke = False - self.fail_invoke = False - self.fail_stop = False - - def plan(self, path: str, profile: Any = None) -> str: - self.plans.append({"path": path, "profile": profile}) - assert path == "agent" - if profile is not None: - assert profile == "hermes_sdk" - return json.dumps(_plan(runtime_mode=self.runtime_mode)) - - def plan_config( - self, - config_json: str, - profiles_json: str | None = None, - base_dir: str | None = None, - ) -> str: - assert json.loads(config_json)["metadata"]["name"] == "demo" - return json.dumps(_plan(runtime_mode=self.runtime_mode)) - - def start_runtime(self, plan_json: str) -> str: - assert json.loads(plan_json)["agent_name"] == "demo" - return json.dumps(_runtime()) - - def invoke_runtime( - self, plan_json: str, runtime_json: str, request_json: str - ) -> str: - if self.block_invoke: - time.sleep(0.2) - if self.fail_invoke: - raise RuntimeError("invoke failed") - plan = json.loads(plan_json) - runtime = json.loads(runtime_json) +def _config(mode: str = "session") -> FabricConfig: + return FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + runtime=RuntimeConfig(mode=mode), + ) + + +@pytest.fixture(name="mock_native") +def mock_native_fixture() -> MagicMock: + mock_native = MagicMock() + mock_native.requests = [] + mock_native.plan.side_effect = lambda path, profiles: json.dumps(_plan()) + mock_native.plan_config.side_effect = ( + lambda config_json, profiles_json, base_dir: json.dumps(_plan()) + ) + mock_native.start_runtime.return_value = json.dumps(_runtime()) + + def invoke(plan_json: str, runtime_json: str, request_json: str) -> str: request = json.loads(request_json) - self.requests.append(request) - turn = len(self.requests) + mock_native.requests.append(request) + turn = len(mock_native.requests) return json.dumps( { - "agent_name": plan["agent_name"], - "profile": plan.get("profile"), - "harness_type": "test.fabric.shim", + "agent_name": "demo", + "profiles": ["typed"], + "harness": "hermes", "adapter_kind": "python", "adapter_id": "test.fabric.shim", - "runtime_id": runtime["runtime_id"], + "runtime_id": "runtime-1", "invocation_id": f"invocation-{turn}", "request_id": request["request_id"], "status": "succeeded", - "events": [ - { - "event_id": f"evt-{turn}", - "kind": "log", - "message": f"turn {turn}", - } - ], "output": { "messages": [ - {"role": "user", "content": request.get("input")}, + {"role": "user", "content": request["input"]}, {"role": "assistant", "content": f"reply-{turn}"}, - ], - "response": f"reply-{turn}", + ] }, "artifacts": {"artifacts": []}, + "events": [], } ) - def stop_runtime(self, plan_json: str, runtime_json: str) -> str: - assert json.loads(plan_json)["agent_name"] == "demo" - assert json.loads(runtime_json)["runtime_id"] == "runtime-1" - self.stopped += 1 - if self.fail_stop: - raise RuntimeError("stop failed") - return json.dumps([]) - - -class NativeClient(FabricClient): - def __init__(self, native: FakeNative) -> None: - super().__init__() - self.native = native + mock_native.invoke_runtime.side_effect = invoke + mock_native.stop_runtime.return_value = json.dumps([]) + return mock_native - def plan(self, path, *, profile=None): # type: ignore[no-untyped-def,override] - return json.loads(self.native.plan(str(path), profile)) - def _native_module(self) -> FakeNative: - return self.native - - def _require_native_module(self, method: str) -> FakeNative: - return self.native +@pytest.fixture(name="native_client") +def native_client_fixture( + monkeypatch: pytest.MonkeyPatch, + mock_native: MagicMock, +) -> FabricClient: + monkeypatch.setattr(client_mod, "_native", mock_native) + return FabricClient() -def _session(native: FakeNative | None = None, overrides: dict | None = None) -> Session: +def _session(mock_native: MagicMock, *, overrides: dict[str, Any] | None = None) -> Session: + client = FabricClient() + client._native_module = lambda: mock_native # type: ignore[method-assign] return Session( - client=NativeClient(native or FakeNative()), + client=client, plan=_plan(), runtime=_runtime(), overrides=overrides, ) -def test_session_constructor_rejects_non_session_runtime_mode(): - with pytest.raises(RuntimeError, match="requires runtime.mode=session"): - Session( - client=NativeClient(FakeNative()), - plan=_plan(runtime_mode="oneshot"), - runtime=_runtime(), - ) - - -async def test_start_creates_session_from_core_runtime_handle() -> None: - native = FakeNative() - session = await NativeClient(native).start("agent", profile="hermes_sdk") - - assert native.plans == [{"path": "agent", "profile": "hermes_sdk"}] - assert session.status is SessionStatus.ACTIVE - assert session.runtime_id == "runtime-1" - assert session.runtime["runtime_id"] == "runtime-1" - assert session.info["runtime_id"] == "runtime-1" - assert "session_id" not in session.info - assert not hasattr(session, "id") - - -async def test_start_accepts_caller_session_id_and_propagates_to_turn_context(): - native = FakeNative() - session = await NativeClient(native).start( - "agent", - profile="hermes_sdk", - session_id="caller-session-123", +async def test_start_session_supports_path_and_typed_sources( + native_client: FabricClient, + mock_native: MagicMock, +): + path_session = await native_client.start_session("agent", profiles=["typed"]) + typed_session = await native_client.start_session( + _config(), + profiles=[], + base_dir=".", + session_id="caller-session", ) - result = await session.invoke("hello session") + assert path_session.runtime_id == "runtime-1" + assert typed_session.session_id == "caller-session" + assert mock_native.plan.call_args.args == ("agent", ["typed"]) + assert mock_native.plan_config.called - assert session.session_id == "caller-session-123" - assert result["runtime_id"] == "runtime-1" - assert native.requests[0]["context"]["session_id"] == "caller-session-123" +async def test_start_session_rejects_non_session_capability( + native_client: FabricClient, + mock_native: MagicMock, +): + mock_native.plan.side_effect = lambda path, profiles: json.dumps(_plan("oneshot")) -async def test_start_rejects_non_session_runtime_mode(): - native = FakeNative(runtime_mode="oneshot") + with pytest.raises(FabricCapabilityError, match="session capability"): + await native_client.start_session("agent") - with pytest.raises(RuntimeError, match="requires runtime.mode=session"): - await NativeClient(native).start("agent", profile="hermes_sdk") - assert native.stopped == 0 - assert native.requests == [] +async def test_start_session_preserves_start_stage( + native_client: FabricClient, + mock_native: MagicMock, +): + mock_native.start_runtime.side_effect = RuntimeError("start failed") + with pytest.raises(FabricRuntimeError, match="start failed") as caught: + await native_client.start_session("agent") -async def test_session_id_defaults_to_runtime_id_for_adapter_context(): - native = FakeNative() - session = _session(native) + assert caught.value.stage == "start" - await session.invoke("hello default session") - assert session.session_id == "runtime-1" - assert native.requests[0]["context"]["session_id"] == "runtime-1" +async def test_start_session_rejects_invalid_overrides_before_start( + native_client: FabricClient, + mock_native: MagicMock, +): + with pytest.raises(FabricConfigError, match="keys must be strings"): + await native_client.start_session( + "agent", + overrides={"nested": {1: "invalid"}}, # type: ignore[dict-item] + ) + mock_native.start_runtime.assert_not_called() -async def test_invoke_uses_stable_runtime_and_does_not_replay_history() -> None: - native = FakeNative() - session = _session(native) - await session.invoke("My name is Robin.") - await session.invoke("What's my name?") +async def test_start_session_rejects_cyclic_overrides_before_start( + native_client: FabricClient, + mock_native: MagicMock, +): + overrides: dict[str, Any] = {} + overrides["cycle"] = overrides - assert [inv["runtime_id"] for inv in session.invocations] == [ - "runtime-1", - "runtime-1", - ] - assert "history" not in native.requests[0]["context"] - assert "history" not in native.requests[1]["context"] - assert session.runtime_id == "runtime-1" - assert len(session.messages) == 2 + with pytest.raises(FabricConfigError, match="JSON-compatible"): + await native_client.start_session("agent", overrides=overrides) + mock_native.start_runtime.assert_not_called() -async def test_request_level_overrides_are_merged() -> None: - native = FakeNative() - session = _session(native, overrides={"a": "session"}) - await session.invoke( - request={"input": "x", "overrides": {"b": "request"}}, - overrides={"c": "turn"}, - ) - assert native.requests[0]["overrides"] == { - "a": "session", - "b": "request", - "c": "turn", - } +async def test_session_reuses_runtime_and_orders_turns(mock_native: MagicMock): + session = _session(mock_native) + first = await session.invoke(input="one") + second = await session.invoke(input="two") -async def test_stream_yields_events_then_result() -> None: - session = _session() - items = [item async for item in session.stream("hi")] + assert isinstance(first, RunResult) + assert first.runtime_id == second.runtime_id == "runtime-1" + assert [request["input"] for request in mock_native.requests] == ["one", "two"] + assert session.messages[-1]["content"] == "reply-2" + assert len(session.invocations) == 2 - assert items[-1]["status"] == "succeeded" - assert items[:-1] and all(event.get("kind") == "log" for event in items[:-1]) +async def test_native_invoke_failure_marks_session_failed(mock_native: MagicMock): + mock_native.invoke_runtime.side_effect = RuntimeError("invoke failed") + session = _session(mock_native) -async def test_stop_is_idempotent_and_blocks_invoke() -> None: - native = FakeNative() - session = _session(native) + with pytest.raises(FabricRuntimeError, match="invoke failed"): + await session.invoke(input="hello") - await session.stop() - await session.stop() + assert session.status is SessionStatus.FAILED + with pytest.raises(FabricStateError, match="failed"): + await session.invoke(input="too late") - assert session.status is SessionStatus.STOPPED - assert native.stopped == 1 - with pytest.raises(RuntimeError): - await session.invoke("too late") +async def test_failed_invoke_is_not_masked_by_context_cleanup(mock_native: MagicMock): + mock_native.invoke_runtime.side_effect = RuntimeError("invoke failed") + session = _session(mock_native) -async def test_stop_rejects_in_flight_turn(monkeypatch: pytest.MonkeyPatch) -> None: - started = asyncio.Event() - release = asyncio.Event() + with pytest.raises(FabricRuntimeError, match="invoke failed"): + async with session: + await session.invoke(input="hello") - async def _blocking(func): # type: ignore[no-untyped-def] - started.set() - await release.wait() - return func() + assert session.status is SessionStatus.FAILED + mock_native.stop_runtime.assert_not_called() - monkeypatch.setattr(client_mod, "_call_blocking", _blocking) - native = FakeNative() - session = _session(native) - first = asyncio.create_task(session.invoke("turn one")) - await started.wait() - with pytest.raises(RuntimeError, match="turn is in flight"): - await session.stop() - assert session.status is SessionStatus.ACTIVE - assert native.stopped == 0 +async def test_session_preserves_non_mapping_message_values(mock_native: MagicMock): + result = json.loads( + mock_native.invoke_runtime.side_effect( + "", + "", + json.dumps({"input": "hello", "request_id": "request-1"}), + ) + ) + result["output"]["messages"] = ["notice", {"role": "assistant", "content": "ok"}, 1] + mock_native.invoke_runtime.side_effect = None + mock_native.invoke_runtime.return_value = json.dumps(result) + session = _session(mock_native) - release.set() - await first + await session.invoke(input="hello") + assert session.messages == ["notice", {"role": "assistant", "content": "ok"}, 1] -async def test_stop_blocks_new_turns_while_shutdown_is_in_progress( - monkeypatch: pytest.MonkeyPatch, -) -> None: - started = asyncio.Event() - release = asyncio.Event() - async def _blocking(func): # type: ignore[no-untyped-def] - if session._closing: # noqa: SLF001 - state-machine regression test - started.set() - await release.wait() - return func() +async def test_session_recursively_merges_overrides(mock_native: MagicMock): + session = _session( + mock_native, + overrides={"limits": {"turns": 2, "tokens": 10}, "mode": "session"}, + ) - monkeypatch.setattr(client_mod, "_call_blocking", _blocking) - native = FakeNative() - session = _session(native) - stop_task = asyncio.create_task(session.stop()) - await started.wait() + await session.invoke( + input="hello", + overrides={"limits": {"tokens": 20}, "mode": None}, + ) - with pytest.raises(RuntimeError, match="shutdown is in progress"): - await session.invoke("too late") + assert mock_native.requests[0]["overrides"] == { + "limits": {"turns": 2, "tokens": 20}, + "mode": None, + } - release.set() - await stop_task - assert session.status is SessionStatus.STOPPED - assert native.stopped == 1 +async def test_stream_yields_terminal_result(mock_native: MagicMock): + items = [item async for item in _session(mock_native).stream(input="hello")] -async def test_stop_failure_clears_shutdown_guard_for_retry() -> None: - native = FakeNative() - native.fail_stop = True - session = _session(native) + assert len(items) == 1 + assert isinstance(items[0], RunResult) - with pytest.raises(RuntimeError, match="stop failed"): - await session.stop() - assert session.status is SessionStatus.ACTIVE - assert session._closing is False # noqa: SLF001 - state-machine regression test +async def test_stop_is_idempotent_and_blocks_future_invokes(mock_native: MagicMock): + session = _session(mock_native) - native.fail_stop = False + await session.stop() await session.stop() assert session.status is SessionStatus.STOPPED - assert native.stopped == 2 - - -async def test_context_manager_auto_stops() -> None: - native = FakeNative() - async with _session(native) as session: - await session.invoke("hi") - assert session.status is SessionStatus.ACTIVE - - assert session.status is SessionStatus.STOPPED - assert native.stopped == 1 + assert mock_native.stop_runtime.call_count == 1 + with pytest.raises(FabricStateError, match="stopped"): + await session.invoke(input="hello") -async def test_cancel_when_idle_marks_cancelled() -> None: - native = FakeNative() - session = _session(native) - await session.cancel() - await session.cancel() - - assert session.status is SessionStatus.CANCELLED - assert native.stopped == 1 - with pytest.raises(RuntimeError): - await session.invoke("after cancel") - - -async def test_cancel_stop_failure_keeps_session_retryable() -> None: - native = FakeNative() - native.fail_stop = True - session = _session(native) - - with pytest.raises(RuntimeError, match="stop failed"): - await session.cancel() - - assert session.status is SessionStatus.ACTIVE - assert native.stopped == 1 - - native.fail_stop = False - await session.cancel() - - assert session.status is SessionStatus.CANCELLED - assert native.stopped == 2 - - -async def test_cancel_blocks_new_turns_while_shutdown_is_in_progress( +async def test_stop_rejects_in_flight_turn( monkeypatch: pytest.MonkeyPatch, -) -> None: + mock_native: MagicMock, +): started = asyncio.Event() release = asyncio.Event() - async def _blocking(func): # type: ignore[no-untyped-def] - if session._closing: # noqa: SLF001 - state-machine regression test - started.set() - await release.wait() + async def blocking(func): # type: ignore[no-untyped-def] + started.set() + await release.wait() return func() - monkeypatch.setattr(client_mod, "_call_blocking", _blocking) - native = FakeNative() - session = _session(native) - cancel_task = asyncio.create_task(session.cancel()) + monkeypatch.setattr(session_mod, "_call_blocking", blocking) + session = _session(mock_native) + turn = asyncio.create_task(session.invoke(input="hello")) await started.wait() - with pytest.raises(RuntimeError, match="shutdown is in progress"): - await session.invoke("too late") + with pytest.raises(FabricStateError, match="in flight"): + await session.stop() release.set() - await cancel_task - assert session.status is SessionStatus.CANCELLED - assert native.stopped == 1 - + await turn -async def test_cancel_aborts_in_flight_turn() -> None: - native = FakeNative() - native.block_invoke = True - session = _session(native) - turn = asyncio.create_task(session.invoke("long running")) - await asyncio.sleep(0) - await session.cancel() - - assert session.status is SessionStatus.CANCELLED - assert native.stopped == 1 - with pytest.raises(asyncio.CancelledError): - await turn - - -async def test_info_summarizes_the_session() -> None: - session = _session() - info = session.info - - assert info["runtime_id"] == "runtime-1" - assert info["agent_name"] == "demo" - assert info["profile"] == "hermes_sdk" - assert info["adapter_kind"] == "python" - assert info["harness_type"] == "test.fabric.shim" - - -async def test_messages_invocations_and_runtime_return_copies() -> None: - session = _session() - await session.invoke("hi") - - messages = session.messages - messages[0]["content"] = "mutated" - invocations = session.invocations - invocations.clear() - runtime = session.runtime - runtime["runtime_id"] = "mutated" - - assert session.messages[0]["content"] == "hi" - assert len(session.invocations) == 1 - assert session.runtime["runtime_id"] == "runtime-1" - - -async def test_invoke_without_output_messages_keeps_transcript() -> None: - class NoMessageNative(FakeNative): - def invoke_runtime(self, plan_json, runtime_json, request_json): # type: ignore[no-untyped-def] - self.requests.append(json.loads(request_json)) - return json.dumps( - { - "status": "succeeded", - "runtime_id": "runtime-1", - "invocation_id": "invocation-1", - "request_id": self.requests[-1]["request_id"], - "output": {}, - } - ) - - session = _session(NoMessageNative()) - await session.invoke("hi") - - assert session.messages == [] - assert len(session.invocations) == 1 - - -async def test_empty_output_messages_replaces_existing_transcript() -> None: - class EmptyMessageNative(FakeNative): - def invoke_runtime(self, plan_json, runtime_json, request_json): # type: ignore[no-untyped-def] - if not self.requests: - return super().invoke_runtime(plan_json, runtime_json, request_json) - request = json.loads(request_json) - self.requests.append(request) - return json.dumps( - { - "status": "succeeded", - "runtime_id": "runtime-1", - "invocation_id": f"invocation-{len(self.requests)}", - "request_id": request["request_id"], - "output": {"messages": []}, - } - ) - - native = EmptyMessageNative() - session = _session(native) - await session.invoke("hi") - assert session.messages - - await session.invoke("reset") - assert session.messages == [] - - -async def test_concurrent_invokes_are_rejected(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_concurrent_invokes_are_rejected( + monkeypatch: pytest.MonkeyPatch, + mock_native: MagicMock, +): started = asyncio.Event() release = asyncio.Event() - async def _blocking(func): # type: ignore[no-untyped-def] + async def blocking(func): # type: ignore[no-untyped-def] started.set() await release.wait() return func() - monkeypatch.setattr(client_mod, "_call_blocking", _blocking) - session = _session() - first = asyncio.create_task(session.invoke("turn one")) + monkeypatch.setattr(session_mod, "_call_blocking", blocking) + session = _session(mock_native) + first = asyncio.create_task(session.invoke(input="one")) await started.wait() - with pytest.raises(RuntimeError): - await session.invoke("turn two") + with pytest.raises(FabricStateError, match="already running"): + await session.invoke(input="two") release.set() await first -async def test_start_requires_native_extension() -> None: - client = FabricClient(command=("fabric",)) - with pytest.raises(FabricNativeUnavailableError): - await client.start("any/agent") - - -async def test_run_collapses_through_core_runtime_lifecycle() -> None: - native = FakeNative() - - result = await NativeClient(native).run("agent", input_text="hello") - - assert result["status"] == "succeeded" - assert result["runtime_id"] == "runtime-1" - assert native.requests[0]["input"] == "hello" - assert native.stopped == 1 - +async def test_run_stops_runtime_after_success_and_failure( + native_client: FabricClient, + mock_native: MagicMock, +): + result = await native_client.run("agent", input="hello") + assert result.status == "succeeded" + assert mock_native.stop_runtime.call_count == 1 -async def test_run_stops_runtime_when_invoke_raises() -> None: - native = FakeNative() - native.fail_invoke = True + mock_native.invoke_runtime.side_effect = RuntimeError("invoke failed") + with pytest.raises(FabricRuntimeError, match="invoke failed"): + await native_client.run("agent", input="hello") + assert mock_native.stop_runtime.call_count == 2 - with pytest.raises(RuntimeError, match="invoke failed"): - await NativeClient(native).run("agent", input_text="hello") - assert native.stopped == 1 - - -async def test_run_preserves_invoke_error_when_stop_also_raises() -> None: - native = FakeNative() - native.fail_invoke = True - native.fail_stop = True - - with pytest.raises(RuntimeError, match="invoke failed"): - await NativeClient(native).run("agent", input_text="hello") +async def test_async_lifecycle_methods_offload_planning( + native_client: FabricClient, + monkeypatch: pytest.MonkeyPatch, +): + event_loop_thread = threading.get_ident() + planning_threads: list[int] = [] + original_plan = native_client.plan - assert native.stopped == 1 + def record_plan(*args: Any, **kwargs: Any): + planning_threads.append(threading.get_ident()) + return original_plan(*args, **kwargs) + monkeypatch.setattr(native_client, "plan", record_plan) -async def test_run_surfaces_stop_error_after_successful_invoke() -> None: - native = FakeNative() - native.fail_stop = True + await native_client.run("agent", input="hello") + session = await native_client.start_session("agent") + await session.stop() + with pytest.raises(FabricCapabilityError, match="service mode"): + await native_client.start_service("agent") - with pytest.raises(RuntimeError, match="stop failed"): - await NativeClient(native).run("agent", input_text="hello") + assert len(planning_threads) == 3 + assert all(thread != event_loop_thread for thread in planning_threads) - assert native.stopped == 1 +async def test_run_surfaces_cleanup_failure_after_success( + native_client: FabricClient, + mock_native: MagicMock, +): + mock_native.stop_runtime.side_effect = RuntimeError("stop failed") -async def test_run_config_collapses_through_core_runtime_lifecycle() -> None: - native = FakeNative() - config = {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "demo"}} + with pytest.raises(FabricRuntimeError, match="stop failed") as caught: + await native_client.run("agent", input="hello") - result = await NativeClient(native).run_config(config, input_text="hello typed") + assert caught.value.stage == "run" + assert mock_native.stop_runtime.call_count == 1 - assert result["status"] == "succeeded" - assert result["runtime_id"] == "runtime-1" - assert native.requests[0]["input"] == "hello typed" - assert native.stopped == 1 +async def test_run_cancellation_keeps_event_loop_responsive_until_cleanup( + native_client: FabricClient, + mock_native: MagicMock, +): + started = threading.Event() + release = threading.Event() + invoke = mock_native.invoke_runtime.side_effect -async def test_start_config_creates_session_from_core_runtime_handle() -> None: - native = FakeNative() - config = {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "demo"}} + def blocking_invoke(*args: str) -> str: + started.set() + release.wait(timeout=1) + return invoke(*args) - session = await NativeClient(native).start_config(config) - result = await session.invoke("hello typed session") + mock_native.invoke_runtime.side_effect = blocking_invoke + run = asyncio.create_task(native_client.run("agent", input="hello")) + await asyncio.to_thread(started.wait, 1) + fallback_release = threading.Timer(1, release.set) + fallback_release.start() - assert session.status is SessionStatus.ACTIVE - assert session.runtime_id == "runtime-1" - assert result["runtime_id"] == "runtime-1" - assert native.requests[0]["input"] == "hello typed session" + run.cancel() + await asyncio.sleep(0.01) + assert not run.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await run + fallback_release.cancel() + assert mock_native.stop_runtime.call_count == 1 -async def test_start_config_accepts_caller_session_id(): - native = FakeNative() - config = {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "demo"}} - session = await NativeClient(native).start_config( - config, - session_id="typed-session-123", - ) - await session.invoke("hello typed session") +async def test_context_manager_stops_runtime(mock_native: MagicMock): + session = _session(mock_native) - assert session.session_id == "typed-session-123" - assert native.requests[0]["context"]["session_id"] == "typed-session-123" + async with session: + await session.invoke(input="hello") + assert session.status is SessionStatus.STOPPED -async def test_start_config_rejects_non_session_runtime_mode(): - native = FakeNative(runtime_mode="oneshot") - config = {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "demo"}} - with pytest.raises(RuntimeError, match="requires runtime.mode=session"): - await NativeClient(native).start_config(config) +async def test_native_unavailable_uses_typed_error(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(client_mod, "_native", None) - assert native.stopped == 0 - assert native.requests == [] + with pytest.raises(FabricNativeUnavailableError, match="native extension"): + FabricClient().plan("agent")