diff --git a/.agents/skills/contribute-api/SKILL.md b/.agents/skills/contribute-api/SKILL.md index c48b51d13..a5c8b394b 100644 --- a/.agents/skills/contribute-api/SKILL.md +++ b/.agents/skills/contribute-api/SKILL.md @@ -26,6 +26,7 @@ runtime or bindings. - `RunRequest.relay_session_root` is forwarded as the typed `AgentRunRequest.relay_session_root` field. Keep Rust, Python, TypeScript, and schema representations in parity. Context keys do not control Relay propagation; preserve the adapter fallback for unusable UUIDs. - OpenShell task commands serialize public SDK config/request/handle/result mappings. Keep forwarding buffered and preserve task-generated IDs, normalized failed results, telemetry references, and declared artifacts. Do not normalize an SDK result as an adapter result. Task paths remain POSIX strings; host artifact destinations stay separate. Unknown outcomes bind the immutable sandbox identity through reattachment and must not be replayed. - Preserve normalized `runtime.timeout_seconds` across host and task SDKs: positive finite values enforce the invocation deadline; omission/null disables the total invocation deadline. Keep lifecycle, health, and cleanup budgets separate, and do not replace disabled invocation deadlines with transport defaults. +- Keep `runtime.response_limits` SDK-owned across host/task execution. Preserve inclusive byte/file budgets, explicit limit diagnostics, and transport allowances derived from configured budgets. Recheck convenience-run responses after cleanup adds stop events or errors, and preserve structured collection-time limit failures. Do not add adapter capability requirements or independent compressed-file ceilings. Use `tests/e2e/response-limits-checklist.md` when changing these paths. - Update docs and examples in the same branch ## Minimum Acceptance diff --git a/README.md b/README.md index 6420e916e..01e688777 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ NeMo Fabric gives applications, evaluation systems, and rollout platforms a cons - **Switch harnesses:** Reuse the consumer integration while adapters handle harness-specific behavior. - **Compare outcomes:** Collect normalized results, errors, artifacts, lifecycle events, and telemetry references. +Invocation response and artifact budgets are configured under +[`runtime.response_limits`](docs/sdk/python.mdx#response-and-artifact-limits), +with the same policy for host and OpenShell execution. + ## Supported Agent Harnesses NeMo Fabric includes adapters for the following harnesses and execution targets. Select a harness name for its adapter documentation: diff --git a/adapters/README.md b/adapters/README.md index 5102ec913..8c7fe9b4b 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -158,7 +158,7 @@ and additive extension maps because their support does not vary by adapter: | `models..max_tokens` | No | No | Yes | Yes | Yes: passed through LiteLLM | No | Yes | Yes | No | No | No | No | No | No | No | No | Yes: translated to the selected API protocol | | `instructions.system` | `replace`, `append` | `replace` with base instructions | `replace` | `replace` | `replace` | `replace` | `replace` | `replace`, `append` | `replace` with Cline override prompt | `replace`, `append` | `replace` with OpenCode base instructions | `replace` with Pi base instructions | `replace`, `append` | `replace` | No | `append` | `replace` | | `runtime.input_schema`, `.output_schema` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `runtime.artifacts`, `.timeout_seconds` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `runtime.artifacts`, `.timeout_seconds`, `.response_limits` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | | `runtime.max_turns` | Yes | No | Yes; maps to LangGraph supersteps | Yes; iteration limit | Yes | No | No | Yes; per invocation | No | No | No | No | No | Yes; maps to build-agent steps | No | No | No | | `environment.provider`, `.control_location`, `.ownership` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | | `environment.workspace`, `.artifacts`, `.env`, `.env_names` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | diff --git a/crates/fabric-cli/src/presets.rs b/crates/fabric-cli/src/presets.rs index e39fca5df..7d6dd41fb 100644 --- a/crates/fabric-cli/src/presets.rs +++ b/crates/fabric-cli/src/presets.rs @@ -286,6 +286,7 @@ fn config( output_schema: "message".to_string(), artifacts: None, timeout_seconds: None, + response_limits: None, max_turns: None, extensions: BTreeMap::new(), }, diff --git a/crates/fabric-cli/templates/rust/main.rs.tmpl b/crates/fabric-cli/templates/rust/main.rs.tmpl index 4055d76af..31e39b464 100644 --- a/crates/fabric-cli/templates/rust/main.rs.tmpl +++ b/crates/fabric-cli/templates/rust/main.rs.tmpl @@ -37,6 +37,7 @@ fn build_config() -> FabricConfig { output_schema: "message".to_string(), artifacts: None, timeout_seconds: {{TIMEOUT_SECONDS}}, + response_limits: None, max_turns: {{MAX_TURNS}}, extensions: BTreeMap::new(), }, diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 72cc01947..47f7c7aad 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -1059,6 +1059,9 @@ pub struct RuntimeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(extend("exclusiveMinimum" = 0.0))] pub timeout_seconds: Option, + /// Response and declared-artifact budgets enforced by the SDK. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_limits: Option, /// Maximum number of harness turns within one invocation. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = u32::MAX))] @@ -1068,6 +1071,53 @@ pub struct RuntimeConfig { pub extensions: BTreeMap, } +/// Inclusive budgets for a buffered invocation result and declared artifact files. +/// Omitted fields retain the default budget. These limits are owned by the SDK, +/// independently of the harness and execution environment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct ResponseLimits { + /// Maximum compact UTF-8 JSON bytes in the normalized RunResult. + #[schemars(range(min = 1))] + pub max_response_bytes: u32, + /// Maximum aggregate uncompressed bytes in unique declared artifact files. + #[schemars(range(min = 1))] + pub max_artifact_bytes: u32, + /// Maximum number of unique declared artifact files. + #[schemars(range(min = 1))] + pub max_artifact_files: u32, + /// Maximum uncompressed bytes in one declared artifact file. + #[schemars(range(min = 1))] + pub max_artifact_file_bytes: u32, +} + +impl Default for ResponseLimits { + fn default() -> Self { + Self { + max_response_bytes: 4 * 1024 * 1024, + max_artifact_bytes: 2 * 1024 * 1024, + max_artifact_files: 16, + max_artifact_file_bytes: 1024 * 1024, + } + } +} + +impl ResponseLimits { + // Transport envelopes and compression are implementation details, not extra + // user budgets. Allow JSON escaping/decimal byte arrays plus bounded metadata. + pub(crate) fn response_transport_bytes(&self) -> usize { + (self.max_response_bytes as usize) + .saturating_mul(6) + .saturating_add(1024 * 1024) + } + + pub(crate) fn artifact_transport_bytes(&self) -> usize { + (self.max_artifact_bytes as usize) + .saturating_mul(8) + .saturating_add(self.response_transport_bytes()) + } +} + fn default_input_schema() -> String { "text".to_string() } @@ -1924,6 +1974,21 @@ pub(crate) fn validate_config(config: &FabricConfig) -> Result<()> { } } } + if let Some(limits) = config.runtime.response_limits { + for (name, value) in [ + ("max_response_bytes", limits.max_response_bytes), + ("max_artifact_bytes", limits.max_artifact_bytes), + ("max_artifact_files", limits.max_artifact_files), + ("max_artifact_file_bytes", limits.max_artifact_file_bytes), + ] { + if value == 0 { + return invalid_config( + format!("runtime.response_limits.{name}"), + "must be greater than zero", + ); + } + } + } if config.runtime.max_turns == Some(0) { return invalid_config("runtime.max_turns", "must be greater than zero"); } @@ -4512,6 +4577,69 @@ mod tests { .expect("typed config") } + #[test] + fn transport_budgets_saturate_at_the_platform_ceiling() { + let limits = ResponseLimits { + max_response_bytes: u32::MAX, + max_artifact_bytes: u32::MAX, + ..Default::default() + }; + let response = u128::from(u32::MAX) * 6 + 1024 * 1024; + let artifact = u128::from(u32::MAX) * 8 + response; + assert_eq!( + limits.response_transport_bytes(), + response.min(usize::MAX as u128) as usize + ); + assert_eq!( + limits.artifact_transport_bytes(), + artifact.min(usize::MAX as u128) as usize + ); + } + + #[test] + fn response_limit_defaults_validation_and_unknown_fields() { + let limits: ResponseLimits = + serde_json::from_value(serde_json::json!({"max_response_bytes": 8})).unwrap(); + assert_eq!(limits.max_response_bytes, 8); + assert_eq!(limits.max_artifact_files, 16); + assert_eq!(limits.max_artifact_bytes, 2 * 1024 * 1024); + assert_eq!(limits.max_artifact_file_bytes, 1024 * 1024); + assert!( + serde_json::from_value::(serde_json::json!({"max_response_byte": 8})) + .is_err() + ); + for field in [ + "max_response_bytes", + "max_artifact_bytes", + "max_artifact_files", + "max_artifact_file_bytes", + ] { + for value in [ + serde_json::json!(0), + serde_json::json!(-1), + serde_json::json!(1.5), + serde_json::json!(true), + serde_json::json!("12"), + Value::Null, + ] { + let mut config = typed_config("test.host"); + let value = serde_json::from_value(serde_json::json!({field: value})); + match value { + Err(_) => {} + Ok(limits) => { + config.runtime.response_limits = Some(limits); + let error = validate_config(&config).expect_err("invalid limit"); + assert!( + error + .to_string() + .contains(&format!("runtime.response_limits.{field}")) + ); + } + } + } + } + } + fn config_with_model(adapter_id: &str, provider: &str) -> FabricConfig { let mut config = typed_config(adapter_id); config.models.insert( diff --git a/crates/fabric-core/src/environment.rs b/crates/fabric-core/src/environment.rs index 9ec5ed886..843de323d 100644 --- a/crates/fabric-core/src/environment.rs +++ b/crates/fabric-core/src/environment.rs @@ -25,7 +25,9 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::config::{ControlLocation, EnvironmentOwnership, EnvironmentPlan, RunPlan}; +use crate::config::{ + ControlLocation, EnvironmentOwnership, EnvironmentPlan, ResponseLimits, RunPlan, +}; use crate::error::{FabricError, Result}; use crate::runtime::{ EnvironmentHandle, EnvironmentReference, absolute_path, new_id, resolve_path, @@ -37,8 +39,8 @@ const OPEN_SHELL_PROVIDER_COMMAND: &str = "fabric-environment-openshell"; const OPEN_SHELL_PROVIDER_COMMAND_ENV: &str = "NEMO_FABRIC_OPEN_SHELL_PROVIDER"; const PROVIDER_PROTOCOL_VERSION: &str = "fabric.environment-provider.v1alpha1"; const MAX_PROVIDER_REQUEST_BYTES: usize = 1024 * 1024; -// Artifact collection can return 2 MiB of bytes as decimal JSON arrays, which -// expand to at most about 8 MiB before the provider protocol envelope. +// Lifecycle/control replies retain a fixed allowance. Invocation and artifact +// replies derive their allowances from the configured runtime budgets. const MAX_PROVIDER_RESPONSE_BYTES: usize = 10 * 1024 * 1024; const DEFAULT_PROVIDER_OPERATION_TIMEOUT_SECONDS: u64 = 30; const PROVIDER_RESPONSE_GRACE_SECONDS: u64 = 15; @@ -283,6 +285,20 @@ pub(crate) fn command_runtime( environment: &EnvironmentHandle, command: &TaskCommand, timeout_seconds: Option, +) -> Result { + command_runtime_with_limits( + environment, + command, + timeout_seconds, + ResponseLimits::default(), + ) +} + +pub(crate) fn command_runtime_with_limits( + environment: &EnvironmentHandle, + command: &TaskCommand, + timeout_seconds: Option, + limits: ResponseLimits, ) -> Result { if environment.provider != OPEN_SHELL_PROVIDER_ID { return Err(FabricError::UnsupportedEnvironmentProvider { @@ -294,6 +310,7 @@ pub(crate) fn command_runtime( environment, command, timeout_seconds, + response_limit_bytes: limits.response_transport_bytes(), }) } @@ -301,9 +318,13 @@ pub(crate) fn command_runtime( pub(crate) fn collect_artifacts( environment: &EnvironmentHandle, paths: &[String], -) -> Result> { - OPEN_SHELL_ENVIRONMENT_PROVIDER - .request(ProviderOperation::CollectArtifacts { environment, paths }) + limits: ResponseLimits, +) -> Result { + OPEN_SHELL_ENVIRONMENT_PROVIDER.request(ProviderOperation::CollectArtifacts { + environment, + paths, + limits, + }) } fn validate_open_shell_profile( @@ -354,6 +375,7 @@ where } let operation_name = operation.name(); let response_timeout = operation.response_timeout(); + let response_limit = operation.response_limit_bytes(); let request_id = new_id("environment-provider-request"); let request = ProviderRequest { protocol_version: PROVIDER_PROTOCOL_VERSION, @@ -389,7 +411,7 @@ where } let process = slot.as_mut().expect("provider process initialized"); if process - .write_request(&encoded, Duration::from_secs(10)) + .write_request(&encoded, Duration::from_secs(10), response_limit) .is_err() { process.shutdown(); @@ -433,14 +455,14 @@ where ); } }; - if output.len() > MAX_PROVIDER_RESPONSE_BYTES { + if output.len() > response_limit { process.shutdown(); *slot = None; return provider_error( operation_name, "response_too_large", format!( - "response is {} bytes; the limit is {MAX_PROVIDER_RESPONSE_BYTES}", + "response is {} bytes; the limit is {response_limit}", output.len() ), ); @@ -545,13 +567,15 @@ where ); } encoded.push(b'\n'); - process.write_request(&encoded, remaining()?).map_err(|_| { - provider_failure( - "runtime_command", - "provider_protocol_error", - "health request could not be delivered".into(), - ) - })?; + process + .write_request(&encoded, remaining()?, MAX_PROVIDER_RESPONSE_BYTES) + .map_err(|_| { + provider_failure( + "runtime_command", + "provider_protocol_error", + "health request could not be delivered".into(), + ) + })?; let output = match process.responses.recv_timeout(remaining()?) { Ok(Ok(output)) => output, Err(RecvTimeoutError::Timeout) => { @@ -649,6 +673,7 @@ where }; let process_key = serde_json::json!([environment_key, identity]).to_string(); let response_timeout = operation.response_timeout(); + let response_limit = operation.response_limit_bytes(); let request = ProviderRequest { protocol_version: PROVIDER_PROTOCOL_VERSION, request_id: &request_id, @@ -679,7 +704,7 @@ where )); } process_guard - .write_request(&encoded, Duration::from_secs(10)) + .write_request(&encoded, Duration::from_secs(10), response_limit) .map_err(|_| { provider_failure( operation_name, @@ -725,10 +750,7 @@ where return Err(error); } }; - if output.len() > MAX_PROVIDER_RESPONSE_BYTES - || output.is_empty() - || output.last() != Some(&b'\n') - { + if output.len() > response_limit || output.is_empty() || output.last() != Some(&b'\n') { retire_runtime_provider_process(&process_key, &process); return provider_error( operation_name, @@ -961,6 +983,7 @@ struct ProviderProcess { #[cfg(unix)] stdin: ChildStdin, responses: Receiver>>, + response_limits: mpsc::Sender, responses_thread: Option>, stderr_thread: Option>, stopping: Arc, @@ -1044,14 +1067,16 @@ impl ProviderProcess { stopping: stopping.clone(), }; let (response_sender, responses) = mpsc::sync_channel(1); + let (response_limits, next_limit) = mpsc::channel::(); let responses_thread = thread::spawn(move || { let mut stdout = BufReader::new(stdout); - loop { + while let Ok(limit) = next_limit.recv() { + if limit == 0 { + break; + } let mut output = Vec::new(); let read = { - let mut bounded = stdout - .by_ref() - .take((MAX_PROVIDER_RESPONSE_BYTES + 1) as u64); + let mut bounded = stdout.by_ref().take(limit.saturating_add(1) as u64); bounded.read_until(b'\n', &mut output) }; match read { @@ -1083,6 +1108,7 @@ impl ProviderProcess { #[cfg(unix)] stdin, responses, + response_limits, responses_thread: Some(responses_thread), stderr_thread: Some(stderr_thread), stopping, @@ -1090,7 +1116,15 @@ impl ProviderProcess { } #[cfg(unix)] - fn write_request(&mut self, encoded: &[u8], timeout: Duration) -> std::io::Result<()> { + fn write_request( + &mut self, + encoded: &[u8], + timeout: Duration, + response_limit: usize, + ) -> std::io::Result<()> { + self.response_limits + .send(response_limit) + .map_err(|_| std::io::ErrorKind::BrokenPipe)?; use std::os::fd::AsRawFd; let descriptor = self.stdin.as_raw_fd(); // The owned pipe remains nonblocking for subsequent bounded writes. @@ -1120,7 +1154,12 @@ impl ProviderProcess { } #[cfg(not(unix))] - fn write_request(&mut self, _encoded: &[u8], _timeout: Duration) -> std::io::Result<()> { + fn write_request( + &mut self, + _encoded: &[u8], + _timeout: Duration, + _response_limit: usize, + ) -> std::io::Result<()> { Err(std::io::ErrorKind::Unsupported.into()) } @@ -1137,6 +1176,7 @@ impl ProviderProcess { } fn shutdown_check(mut self, started: Instant, budget: Duration) { + let _ = self.response_limits.send(0); self.stopping .store(true, std::sync::atomic::Ordering::Relaxed); reap_health_provider(self.child, started, budget); @@ -1156,6 +1196,7 @@ impl ProviderProcess { } fn shutdown(&mut self) { + let _ = self.response_limits.send(0); let _ = self.child.kill(); let _ = self.child.wait(); if let Some(thread) = self.responses_thread.take() { @@ -1204,10 +1245,12 @@ enum ProviderOperation<'a> { environment: &'a EnvironmentHandle, command: &'a TaskCommand, timeout_seconds: Option, + response_limit_bytes: usize, }, CollectArtifacts { environment: &'a EnvironmentHandle, paths: &'a [String], + limits: ResponseLimits, }, Release { environment: &'a EnvironmentHandle, @@ -1215,6 +1258,16 @@ enum ProviderOperation<'a> { } impl ProviderOperation<'_> { + fn response_limit_bytes(&self) -> usize { + match self { + Self::RuntimeCommand { + response_limit_bytes, + .. + } => *response_limit_bytes, + Self::CollectArtifacts { limits, .. } => limits.artifact_transport_bytes(), + _ => MAX_PROVIDER_RESPONSE_BYTES, + } + } fn name(&self) -> &'static str { match self { Self::Prepare { .. } => "prepare", @@ -1416,6 +1469,7 @@ mod tests { environment: &environment, command: &command, timeout_seconds: Some(315), + response_limit_bytes: MAX_PROVIDER_RESPONSE_BYTES, }; assert_eq!( runtime.response_timeout().expect("finite timeout"), @@ -1426,6 +1480,7 @@ mod tests { environment: &environment, command: &command, timeout_seconds: None, + response_limit_bytes: MAX_PROVIDER_RESPONSE_BYTES, }; assert_eq!(unlimited.response_timeout(), None); @@ -1433,6 +1488,7 @@ mod tests { let artifacts = ProviderOperation::CollectArtifacts { environment: &environment, paths: &paths, + limits: ResponseLimits::default(), }; assert_eq!( artifacts.response_timeout().expect("finite timeout"), @@ -1455,7 +1511,11 @@ mod tests { let mut process = ProviderProcess::spawn(command, "prepare").expect("start provider"); let started = Instant::now(); let error = process - .write_request(&vec![b'x'; 1024 * 1024], Duration::from_millis(30)) + .write_request( + &vec![b'x'; 1024 * 1024], + Duration::from_millis(30), + MAX_PROVIDER_RESPONSE_BYTES, + ) .expect_err("blocked pipe write must expire"); assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); assert!(started.elapsed() < Duration::from_secs(1)); @@ -1547,6 +1607,7 @@ else: environment: &environment, command: &task, timeout_seconds: Some(1), + response_limit_bytes: MAX_PROVIDER_RESPONSE_BYTES, }, timeout_seconds, ); @@ -1617,3 +1678,20 @@ pub(crate) struct CollectedArtifact { pub(crate) path: String, pub(crate) content: Vec, } + +/// Artifact collection either returns complete files or a correlated limit observation. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum CollectedArtifacts { + Files(Vec), + Limited(ArtifactLimitFailure), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ArtifactLimitFailure { + pub(crate) limit: String, + pub(crate) configured: u64, + pub(crate) observed: u64, + pub(crate) asset: String, +} diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index 56a68b44e..241d1ad72 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -42,10 +42,10 @@ pub use config::{ InstructionMode, InstructionsConfig, McpAuthenticationConfig, McpConfig, McpExposure, McpServerPlan, McpTransport, MetadataConfig, ModelConfig, OAuthTokenEndpointAuthMethod, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, ResolvedAdapterTargetDescriptor, - RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, - TelemetryProvider, TelemetryProviderConfig, ToolDefinitionConfig, ToolsConfig, WorkflowConfig, - WorkflowEntrypointConfig, WorkflowTargetSpec, inspect_adapter_config, load_adapter_descriptor, - load_adapter_target_descriptor, resolve_diagnostic_plan_from_config, + ResponseLimits, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, + TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, ToolDefinitionConfig, ToolsConfig, + WorkflowConfig, WorkflowEntrypointConfig, WorkflowTargetSpec, inspect_adapter_config, + load_adapter_descriptor, load_adapter_target_descriptor, resolve_diagnostic_plan_from_config, resolve_diagnostic_plan_from_config_with_adapter_directories, resolve_run_plan_from_config, resolve_run_plan_from_config_with_adapter_directories, }; diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index fd6552887..7b5098bfb 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -38,6 +38,7 @@ use crate::system_stats::ResourceObservation; use crate::system_stats::{CgroupStats, InvocationSystemStats, StatsSession, SystemReader}; mod openshell; +mod response_limits; use openshell::OpenShellRuntimeAdapter; pub(crate) fn remote_check_agent( @@ -181,6 +182,14 @@ pub struct RunResult { pub metadata: BTreeMap, } +impl RunResult { + /// Enforce the response byte budget after the result has been finalized. + /// A breach replaces returned payloads with a bounded limit diagnostic. + pub fn enforce_response_limit(&mut self, limits: crate::config::ResponseLimits) -> Result<()> { + response_limits::enforce_response(self, limits) + } +} + /// Runtime completion status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -912,14 +921,22 @@ enum LocalServiceState { /// Invoke a NeMo Fabric run plan. pub fn run_plan(plan: &RunPlan, request: RunRequest) -> Result { let runtime = start_runtime(plan)?; - let mut result = match invoke_runtime(plan, &runtime, request) { + let result = match invoke_runtime(plan, &runtime, request) { Ok(result) => result, Err(error) => { let _ = stop_runtime(plan, &runtime); return Err(error); } }; - match stop_runtime(plan, &runtime) { + finish_run_result(plan, result, stop_runtime(plan, &runtime)) +} + +fn finish_run_result( + plan: &RunPlan, + mut result: RunResult, + stopped: Result>, +) -> Result { + match stopped { Ok(events) => result.events.extend(events), Err(error) if result.status == RunStatus::Succeeded => { result.status = RunStatus::Failed; @@ -933,6 +950,7 @@ pub fn run_plan(plan: &RunPlan, request: RunRequest) -> Result { } Err(_) => {} } + result.enforce_response_limit(plan.config.runtime.response_limits.unwrap_or_default())?; Ok(result) } @@ -1256,9 +1274,19 @@ pub fn invoke_runtime( validate_runtime_handle(plan, runtime)?; if uses_local_host(plan) { if runtime.environment.provider == "openshell" { - return OpenShellRuntimeAdapter.invoke(plan, runtime, request); + let mut result = OpenShellRuntimeAdapter.invoke(plan, runtime, request)?; + response_limits::enforce_response( + &mut result, + plan.config.runtime.response_limits.unwrap_or_default(), + )?; + return Ok(result); } - return LocalHostAdapter.invoke(plan, runtime, request); + let mut result = LocalHostAdapter.invoke(plan, runtime, request)?; + response_limits::enforce_response( + &mut result, + plan.config.runtime.response_limits.unwrap_or_default(), + )?; + return Ok(result); } Err(FabricError::UnsupportedRuntimeAdapter { harness: harness(plan), @@ -1331,7 +1359,13 @@ pub fn invoke_openai_stream( if runtime.environment.provider == "openshell" { return OpenShellRuntimeAdapter.invoke_openai_stream(plan, runtime, request, transport); } - return LocalHostAdapter.invoke_openai_stream(plan, runtime, request, transport); + let mut result = + LocalHostAdapter.invoke_openai_stream(plan, runtime, request, transport)?; + response_limits::enforce_response( + &mut result, + plan.config.runtime.response_limits.unwrap_or_default(), + )?; + return Ok(result); } Err(FabricError::UnsupportedRuntimeAdapter { harness: harness(plan), @@ -2431,7 +2465,7 @@ fn run_local_host_invocation_with_timeout( serde_json::to_value(&agent_result.extensions).map_err(FabricError::SerializeJson)?, ); } - Ok(RunResult { + let mut result = RunResult { agent_name: plan.agent_name.clone(), harness: harness(plan), adapter_kind: adapter_kind(plan), @@ -2452,7 +2486,12 @@ fn run_local_host_invocation_with_timeout( .collect(), events, metadata, - }) + }; + response_limits::enforce_local_artifacts( + &mut result, + plan.config.runtime.response_limits.unwrap_or_default(), + )?; + Ok(result) } fn invalidate_timed_out_local_host( @@ -6160,6 +6199,33 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn run_plan_checks_stop_events_and_stop_errors_in_final_budget() { + let (root, mut plan) = local_host_plan("success"); + let original = run_plan(&plan, RunRequest::text("test")).unwrap(); + let initial_bytes = serde_json::to_vec(&original).unwrap().len() as u32; + plan.config.runtime.response_limits = Some(crate::ResponseLimits { + max_response_bytes: initial_bytes, + ..Default::default() + }); + let event = event_with_metadata("stop", "x".repeat(2048), BTreeMap::new()); + let stop_error = lifecycle_error( + AdapterLifecycleOperation::Stop, + "test", + "stop_failed", + "x".repeat(2048), + "", + ); + for stopped in [Ok(vec![event]), Err(stop_error)] { + let result = finish_run_result(&plan, original.clone(), stopped).unwrap(); + assert_eq!(result.status, RunStatus::Failed); + let error = result.error.unwrap(); + assert_eq!(error.code, "response_limit_exceeded"); + assert!(error.metadata["observed"].as_u64().unwrap() > u64::from(initial_bytes)); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn run_plan_stops_local_host_after_invoke_failure() { let (root, mut plan) = local_host_plan("invoke_failure"); @@ -6221,6 +6287,8 @@ for line in sys.stdin: #[test] fn local_host_enriches_typed_usage_artifacts_and_extensions() { let (root, plan) = local_host_plan("typed_result"); + fs::create_dir_all(root.join("artifacts/reports")).unwrap(); + fs::write(root.join("artifacts/reports/result.json"), "{}").unwrap(); let result = run_plan(&plan, RunRequest::text("enrich")).expect("normalized result"); diff --git a/crates/fabric-core/src/runtime/openshell.rs b/crates/fabric-core/src/runtime/openshell.rs index 88bfffc1c..05b5a68d2 100644 --- a/crates/fabric-core/src/runtime/openshell.rs +++ b/crates/fabric-core/src/runtime/openshell.rs @@ -12,7 +12,7 @@ use super::*; use crate::config::{DiscoveryConfig, EnvironmentConfig, ResolutionStrategy}; -use crate::environment::{collect_artifacts, command_runtime}; +use crate::environment::{collect_artifacts, command_runtime, command_runtime_with_limits}; use crate::task_command::{TaskCommand, TaskCommandResponse}; pub(super) struct OpenShellRuntimeAdapter; @@ -461,10 +461,11 @@ impl RuntimeAdapter for OpenShellRuntimeAdapter { let key = claim(runtime, false)?.expect("active invocation binding"); let mut confirmed = false; let result = (|| { - let response = command_runtime( + let response = command_runtime_with_limits( &runtime.environment, &command, timeout.map(|seconds| seconds.saturating_add(15)), + plan.config.runtime.response_limits.unwrap_or_default(), )?; if let TaskCommandResponse::Rejected { error } = &response { confirmed = error.effects == "none"; @@ -737,17 +738,44 @@ fn collect_result_artifacts( return Err(invalid_environment("artifact.identity")); } let base = resolve_path(&plan.base_dir, root); - let root = destination_root(&base, &result.runtime_id, &result.invocation_id)?; let mut seen = BTreeSet::new(); let unique: Vec = paths .iter() .filter(|path| seen.insert((*path).clone())) .cloned() .collect(); - let files = collect_artifacts(&runtime.environment, &unique)?; + let limits = plan.config.runtime.response_limits.unwrap_or_default(); + let files = match collect_artifacts(&runtime.environment, &unique, limits)? { + crate::environment::CollectedArtifacts::Files(files) => files, + crate::environment::CollectedArtifacts::Limited(error) => { + let configured = match error.limit.as_str() { + "max_artifact_bytes" => limits.max_artifact_bytes, + "max_artifact_file_bytes" => limits.max_artifact_file_bytes, + "max_artifact_files" => limits.max_artifact_files, + _ => return Err(invalid_environment("artifact.limit")), + } as u64; + if error.configured != configured + || error.observed <= configured + || !unique.contains(&error.asset) + || error.asset.len() > 4096 + || error.asset.chars().any(char::is_control) + { + return Err(invalid_environment("artifact.limit")); + } + response_limits::reject( + result, + &error.limit, + configured, + error.observed, + &error.asset, + ); + return Ok(()); + } + }; if files.len() != unique.len() { return Err(invalid_environment("artifact.correlation")); } + let root = destination_root(&base, &result.runtime_id, &result.invocation_id)?; for (path, file) in unique.iter().zip(files) { if &file.path != path { return Err(invalid_environment("artifact.correlation")); @@ -799,7 +827,7 @@ fn collect_result_artifacts( Ok(()) } -fn is_internal_sdk_artifact(path: &str) -> bool { +pub(super) fn is_internal_sdk_artifact(path: &str) -> bool { path == ".fabric" || path.starts_with(".fabric/") || matches!(path, "workspace.patch" | "workspace-status.txt") diff --git a/crates/fabric-core/src/runtime/response_limits.rs b/crates/fabric-core/src/runtime/response_limits.rs new file mode 100644 index 000000000..d4e80ef96 --- /dev/null +++ b/crates/fabric-core/src/runtime/response_limits.rs @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! SDK-owned response budgets shared by host and task execution. + +use super::*; +use crate::config::ResponseLimits; + +pub(super) fn reject( + result: &mut RunResult, + limit: &str, + configured: u64, + observed: u64, + asset: &str, +) { + result.status = RunStatus::Failed; + result.output = Value::Null; + result.artifacts = ArtifactManifest::default(); + result.metadata.clear(); + result.events.clear(); + result.telemetry.clear(); + result.usage = None; + result.execution = None; + result.system_stats = None; + result.resource_snapshot = None; + result.error = Some(ErrorInfo { + stage: ErrorStage::Invoke, + code: "response_limit_exceeded".into(), + message: format!( + "runtime.response_limits.{limit}={configured} exceeded by {asset}: observed {observed}. Increase this limit and invoke again; the result was rejected without truncation." + ), + retryable: false, + metadata: BTreeMap::from([ + ("limit".into(), Value::String(limit.into())), + ("configured".into(), Value::from(configured)), + ("observed".into(), Value::from(observed)), + ("asset".into(), Value::String(asset.into())), + ]), + }); +} + +pub(super) fn enforce_response(result: &mut RunResult, limits: ResponseLimits) -> Result<()> { + struct Counter(u64); + impl Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(bytes.len() as u64); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut counter = Counter(0); + serde_json::to_writer(&mut counter, result).map_err(FabricError::SerializeJson)?; + // Exempt only a small failed diagnostic, never arbitrary adapter output + // that happens to use the same error code. + if counter.0 <= 64 * 1024 && is_sdk_limit_diagnostic(result) { + return Ok(()); + } + if counter.0 > u64::from(limits.max_response_bytes) { + reject( + result, + "max_response_bytes", + limits.max_response_bytes.into(), + counter.0, + "RunResult", + ); + } + Ok(()) +} + +fn is_sdk_limit_diagnostic(result: &RunResult) -> bool { + let Some(error) = &result.error else { + return false; + }; + if error.code != "response_limit_exceeded" { + return false; + } + let (Some(limit), Some(configured), Some(observed), Some(asset)) = ( + error.metadata.get("limit").and_then(Value::as_str), + error.metadata.get("configured").and_then(Value::as_u64), + error.metadata.get("observed").and_then(Value::as_u64), + error.metadata.get("asset").and_then(Value::as_str), + ) else { + return false; + }; + if ![ + "max_response_bytes", + "max_artifact_bytes", + "max_artifact_files", + "max_artifact_file_bytes", + ] + .contains(&limit) + || configured == 0 + || configured > u64::from(u32::MAX) + || observed <= configured + || asset.is_empty() + || asset.len() > 4096 + || asset.chars().any(char::is_control) + || (limit == "max_response_bytes" && asset != "RunResult") + { + return false; + } + // Match the complete output of the one SDK rejection constructor, including + // empty payload fields, exact diagnostic text/metadata and retry semantics. + let mut canonical = result.clone(); + reject(&mut canonical, limit, configured, observed, asset); + *result == canonical +} + +pub(super) fn enforce_local_artifacts( + result: &mut RunResult, + limits: ResponseLimits, +) -> Result<()> { + let mut paths = BTreeSet::new(); + let mut total = 0_u64; + for artifact in &result.artifacts.artifacts { + let relative = result + .artifacts + .root + .as_ref() + .and_then(|root| artifact.path.strip_prefix(root).ok()) + .unwrap_or(&artifact.path); + if openshell::is_internal_sdk_artifact(&relative.to_string_lossy()) { + continue; + } + if !paths.insert(artifact.path.clone()) { + continue; + } + let asset = artifact.path.to_string_lossy().into_owned(); + if paths.len() as u64 > u64::from(limits.max_artifact_files) { + reject( + result, + "max_artifact_files", + limits.max_artifact_files.into(), + paths.len() as u64, + &asset, + ); + return Ok(()); + } + let metadata = std::fs::metadata(&artifact.path).map_err(|source| FabricError::Read { + path: artifact.path.clone(), + source, + })?; + // Directories and non-file references do not have transferable bytes. + // The OpenShell collector additionally validates regular-file identity. + if !metadata.is_file() { + continue; + } + let bytes = metadata.len(); + if bytes > u64::from(limits.max_artifact_file_bytes) { + reject( + result, + "max_artifact_file_bytes", + limits.max_artifact_file_bytes.into(), + bytes, + &asset, + ); + return Ok(()); + } + total = total.saturating_add(bytes); + if total > u64::from(limits.max_artifact_bytes) { + reject( + result, + "max_artifact_bytes", + limits.max_artifact_bytes.into(), + total, + &asset, + ); + return Ok(()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn result() -> RunResult { + serde_json::from_value(serde_json::json!({ + "agent_name": "limits", "harness": "echo", "adapter_kind": "process", + "runtime_id": "runtime-test", "invocation_id": "invocation-test", + "request_id": "request-test", "status": "succeeded", "output": "é😀" + })) + .expect("result fixture") + } + + #[test] + fn response_bytes_below_at_above_use_compact_utf8_json() { + let original = result(); + let bytes = serde_json::to_vec(&original).unwrap().len() as u32; + for delta in [-1_i32, 0, 1] { + let mut result = original.clone(); + let limits = ResponseLimits { + max_response_bytes: bytes.checked_add_signed(delta).unwrap(), + ..ResponseLimits::default() + }; + enforce_response(&mut result, limits).unwrap(); + assert_eq!(result.status == RunStatus::Succeeded, delta >= 0); + if delta < 0 { + let error = result.error.as_ref().unwrap().clone(); + assert_eq!(error.metadata["asset"], "RunResult"); + assert_eq!(error.metadata["observed"], bytes); + assert!(error.message.contains("max_response_bytes")); + assert_eq!(result.output, Value::Null); + // Even a one-byte budget must deliver an explicit rejection. + enforce_response( + &mut result, + ResponseLimits { + max_response_bytes: 1, + ..limits + }, + ) + .unwrap(); + assert_eq!(result.error.as_ref().unwrap(), &error); + } + } + } + + #[test] + fn diagnostic_exemption_requires_the_exact_sdk_rejection_shape() { + let mut original = result(); + reject(&mut original, "max_response_bytes", 1, 1000, "RunResult"); + assert!(is_sdk_limit_diagnostic(&original)); + for (field, value) in [ + ("usage", serde_json::json!({})), + ("metadata", serde_json::json!({"payload": "x"})), + ( + "events", + serde_json::json!([{"event_id":"stop", "timestamp_millis":0, "kind":"stop", "message":"payload"}]), + ), + ("telemetry", serde_json::json!([{"relay_enabled":false}])), + ("artifacts", serde_json::json!({"root":"/tmp"})), + ] { + let mut mapping = serde_json::to_value(&original).unwrap(); + mapping[field] = value; + let mut result: RunResult = serde_json::from_value(mapping).unwrap(); + assert!(!is_sdk_limit_diagnostic(&result), "{field}"); + enforce_response( + &mut result, + ResponseLimits { + max_response_bytes: 1, + ..Default::default() + }, + ) + .unwrap(); + assert!(is_sdk_limit_diagnostic(&result)); + assert_eq!( + result.error.unwrap().metadata["limit"], + "max_response_bytes" + ); + } + let mut result = original.clone(); + result + .error + .as_mut() + .unwrap() + .metadata + .insert("extra".into(), Value::Bool(true)); + assert!(!is_sdk_limit_diagnostic(&result)); + let mut result = original; + result.error.as_mut().unwrap().message = "arbitrary payload".into(); + assert!(!is_sdk_limit_diagnostic(&result)); + } + + #[test] + fn adapter_error_code_cannot_exempt_oversized_output_or_usage() { + let mut result = result(); + result.status = RunStatus::Failed; + result.error = Some(ErrorInfo { + stage: ErrorStage::Invoke, + code: "response_limit_exceeded".into(), + message: "adapter error".into(), + retryable: false, + metadata: BTreeMap::new(), + }); + result.usage = Some(RunUsage { + metadata: BTreeMap::from([("large".into(), Value::String("x".repeat(100_000)))]), + ..RunUsage::default() + }); + enforce_response( + &mut result, + ResponseLimits { + max_response_bytes: 1, + ..ResponseLimits::default() + }, + ) + .unwrap(); + assert!(result.usage.is_none()); + assert!(result.output.is_null()); + assert!(serde_json::to_vec(&result).unwrap().len() < 2048); + assert_eq!( + result.error.unwrap().metadata["limit"], + "max_response_bytes" + ); + } + + #[test] + fn each_artifact_budget_is_independent_and_inclusive() { + let root = std::env::temp_dir().join(new_id("response-limits")); + std::fs::create_dir_all(root.join(".fabric")).unwrap(); + let mut original = result(); + original.artifacts.root = Some(root.clone()); + for (name, bytes) in [ + ("first.png", 6), + ("second.png", 4), + (".fabric/stdout.txt", 100), + ] { + let path = root.join(name); + std::fs::write(&path, vec![0_u8; bytes]).unwrap(); + original.artifacts.artifacts.push(ArtifactRef { + name: name.into(), + kind: "file".into(), + path, + media_type: None, + metadata: BTreeMap::new(), + }); + } + // Repeated references count once. + original + .artifacts + .artifacts + .push(original.artifacts.artifacts[0].clone()); + for (field, boundary) in [ + ("max_artifact_bytes", 10_u32), + ("max_artifact_file_bytes", 6), + ("max_artifact_files", 2), + ] { + for delta in [-1_i32, 0, 1] { + let mut limits = ResponseLimits::default(); + let value = boundary.checked_add_signed(delta).unwrap(); + match field { + "max_artifact_bytes" => limits.max_artifact_bytes = value, + "max_artifact_file_bytes" => limits.max_artifact_file_bytes = value, + _ => limits.max_artifact_files = value, + } + let mut result = original.clone(); + enforce_local_artifacts(&mut result, limits).unwrap(); + assert_eq!( + result.status == RunStatus::Succeeded, + delta >= 0, + "{field} {value}" + ); + if delta < 0 { + let error = result.error.unwrap(); + assert_eq!(error.metadata["limit"], field); + assert!(error.message.contains(".png")); + assert!(result.artifacts.artifacts.is_empty()); + } + } + } + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/fabric-python/src/lib.rs b/crates/fabric-python/src/lib.rs index a340a1301..0d7e4f702 100644 --- a/crates/fabric-python/src/lib.rs +++ b/crates/fabric-python/src/lib.rs @@ -16,7 +16,7 @@ use nemo_fabric_core::{ resolve_diagnostic_plan_from_config_with_adapter_directories, resolve_run_plan_from_config_with_adapter_directories, run_plan, }; -use pyo3::exceptions::{PyRuntimeError, PyTimeoutError}; +use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError}; use pyo3::prelude::*; pyo3::create_exception!( @@ -358,6 +358,20 @@ fn runtime_resource_snapshot( to_json(&snapshot) } +/// Recheck the completed result after Python-owned lifecycle cleanup. +#[pyfunction] +fn finalize_run_result(result_json: String, max_response_bytes: u32) -> PyResult { + let mut result: nemo_fabric_core::RunResult = serde_json::from_str(&result_json) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + result + .enforce_response_limit(nemo_fabric_core::ResponseLimits { + max_response_bytes, + ..Default::default() + }) + .map_err(to_py_error)?; + to_json(&result) +} + #[pymodule] fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(check_environment, m)?)?; @@ -372,6 +386,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.py().get_type::(), )?; m.add_function(wrap_pyfunction!(version, m)?)?; + m.add_function(wrap_pyfunction!(finalize_run_result, m)?)?; m.add_function(wrap_pyfunction!(plan_config, m)?)?; m.add_function(wrap_pyfunction!(inspect_adapter_metadata, m)?)?; m.add_function(wrap_pyfunction!(doctor_config, m)?)?; diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index cdf4297b0..3b58a3c6f 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -58,6 +58,7 @@ SPDX-License-Identifier: Apache-2.0 --> - [`models.RelayOpenTelemetryConfig`](./nemo_fabric.models.md#class-relayopentelemetryconfig): NeMo Relay typed OpenTelemetry destination configuration. - [`models.RelayOpenTelemetryEndpointConfig`](./nemo_fabric.models.md#class-relayopentelemetryendpointconfig): One typed NeMo Relay OpenTelemetry destination. - [`models.RelayS3StorageConfig`](./nemo_fabric.models.md#class-relays3storageconfig): NeMo Relay ATIF S3 storage configuration. +- [`models.ResponseLimits`](./nemo_fabric.models.md#class-responselimits): Inclusive byte and file budgets, enforced by the SDK in every environment. - [`models.RunRequest`](./nemo_fabric.models.md#class-runrequest): One validated NeMo Fabric invocation request. - [`models.RuntimeConfig`](./nemo_fabric.models.md#class-runtimeconfig): Invocation runtime contract. - [`models.SkillConfig`](./nemo_fabric.models.md#class-skillconfig): Skill capability configuration. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 0ee196fc2..2c92cfb72 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -472,6 +472,79 @@ Returns the set of fields that have been explicitly set on this model instance. +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +def from_mapping(value: Mapping[str, Any]) -> Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `ResponseLimits` + +Inclusive byte and file budgets, enforced by the SDK in every environment. + + + +### Fields + +The model defines the following fields: + +| Field | Type | Required | Default | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `max_response_bytes` | `int` | No | `4194304` | `Strict(strict=True), Gt(gt=0), Le(le=4294967295)` | — | +| `max_artifact_bytes` | `int` | No | `2097152` | `Strict(strict=True), Gt(gt=0), Le(le=4294967295)` | — | +| `max_artifact_files` | `int` | No | `16` | `Strict(strict=True), Gt(gt=0), Le(le=4294967295)` | — | +| `max_artifact_file_bytes` | `int` | No | `1048576` | `Strict(strict=True), Gt(gt=0), Le(le=4294967295)` | — | + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + **Returns:** A set of strings representing the fields that have been set, i.e. that were not filled from defaults. @@ -520,6 +593,7 @@ The model defines the following fields: | `artifacts` | `str \| Path \| None` | No | `None` | — | — | | `timeout_seconds` | `float \| None` | No | `None` | `Gt(gt=0), _PydanticGeneralMetadata(allow_inf_nan=False)` | — | | `max_turns` | `int \| None` | No | `None` | `Gt(gt=0), Le(le=4294967295)` | — | +| `response_limits` | `ResponseLimits \| None` | No | `None` | — | — | --- diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx index e9363dff6..3c1620937 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx @@ -3,7 +3,7 @@ title: "Module adapter_contract" sidebar-title: "adapter_contract" slug: "/reference/api/rust-library-reference/nemo-fabric-core/adapter_contract" description: "Shared southbound adapter contract metadata." -position: 163 +position: 164 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx index a0b3d982d..1fa2c4419 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx @@ -3,7 +3,7 @@ title: "Module agent_config" sidebar-title: "agent_config" slug: "/reference/api/rust-library-reference/nemo-fabric-core/agent_config" description: "Configuration projected southbound to an adapter target." -position: 164 +position: 165 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx index d983cd30d..2cb269040 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx @@ -3,7 +3,7 @@ title: "Module agent_execution" sidebar-title: "agent_execution" slug: "/reference/api/rust-library-reference/nemo-fabric-core/agent_execution" description: "Request and result structures exchanged with an adapter target." -position: 165 +position: 166 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx index 76e21a22b..ea63a4d62 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx @@ -2,7 +2,7 @@ title: "Module check" sidebar-title: "check" description: "Runtime health checks and shared contracts." -position: 166 +position: 167 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx index 40e387ace..65c0f3eaf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 66 +position: 67 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx index e121e6b73..010851195 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Component responsible for executing a configured capability." -position: 67 +position: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx index 74dc754f8..b1e6d19ac 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 76 +position: 77 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx index bbf8ff9e5..2e4357780 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 77 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx index d53f00f8d..d699b5db0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Sink Config" sidebar-title: "RelayAtofSinkConfig" description: "Relay ATOF sink configuration." -position: 78 +position: 79 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx index 892413a9e..9ee176668 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Field Name Policy" sidebar-title: "RelayAtofStreamFieldNamePolicy" description: "Relay ATOF stream field-name policy." -position: 79 +position: 80 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx index ea48753dd..a90cec5f3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Transport" sidebar-title: "RelayAtofStreamTransport" description: "Relay ATOF stream transport." -position: 80 +position: 81 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx index beb3da6eb..ba9321f6c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Open Telemetry Endpoint Type" sidebar-title: "RelayOpenTelemetryEndpointType" description: "Relay OpenTelemetry span projection." -position: 81 +position: 82 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx index da13057aa..9b78e9a7e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Open Telemetry Mark Projection" sidebar-title: "RelayOpenTelemetryMarkProjection" description: "Relay OpenTelemetry mark projection policy." -position: 82 +position: 83 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx index 8db6e0872..a73f2ad1b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 83 +position: 84 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx index 95ec26616..506a2c506 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 84 +position: 85 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx index b8b147e34..c469c5bc7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx @@ -2,7 +2,7 @@ title: "Enum Telemetry Provider" sidebar-title: "TelemetryProvider" description: "Telemetry runtime provider." -position: 83 +position: 84 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx index a065134f8..771f41e95 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 90 +position: 91 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx index 5a2da278f..380f348da 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_target_descriptor" sidebar-title: "load_adapter_target_descriptor" description: "Load an Adapter Target Descriptor from declarative JSON metadata." -position: 91 +position: 92 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx index 040c00886..ab974b263 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed NVIDIA NeMo Fabric config into a runnable plan." -position: 92 +position: 93 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index 479d65fe3..ee921720c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "NeMo Fabric config models and loading helpers." -position: 167 +position: 168 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -64,6 +64,7 @@ NeMo Fabric config models and loading helpers. - [ResolveContext](struct-resolvecontext.mdx): Source context used when resolving an in-memory NeMo Fabric config. - [ResolvedAdapterDescriptor](struct-resolvedadapterdescriptor.mdx): Adapter descriptor selected for a run plan. - [ResolvedAdapterTargetDescriptor](struct-resolvedadaptertargetdescriptor.mdx): Adapter target descriptor selected for a run plan. +- [ResponseLimits](struct-responselimits.mdx): Inclusive budgets for a buffered invocation result and declared artifact files. Omitted fields retain the default budget. These limits are owned by the SDK, independently of the harness and execution environment. - [RunPlan](struct-runplan.mdx): Resolved NeMo Fabric run plan. - [RuntimeCapabilities](struct-runtimecapabilities.mdx): Lifecycle behavior implemented by a resolved runtime path. - [RuntimeConfig](struct-runtimeconfig.mdx): Invocation runtime contract. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-responselimits.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-responselimits.mdx new file mode 100644 index 000000000..28be67f4e --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-responselimits.mdx @@ -0,0 +1,122 @@ +--- +title: "Struct Response Limits" +sidebar-title: "ResponseLimits" +description: "Inclusive budgets for a buffered invocation result and declared artifact files. Omitted fields retain the default budget. These limits are owned by the SDK, independently of the harness and execution environment." +position: 77 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
u32,\n    pub max_artifact_bytes: u32,\n    pub max_artifact_files: u32,\n    pub max_artifact_file_bytes: u32,\n}"}} />
+ +Inclusive budgets for a buffered invocation result and declared artifact files. Omitted fields retain the default budget. These limits are owned by the SDK, independently of the harness and execution environment. + +## Fields + +### `max_response_bytes: u32` + +Maximum compact UTF-8 JSON bytes in the normalized RunResult. + +### `max_artifact_bytes: u32` + +Maximum aggregate uncompressed bytes in unique declared artifact files. + +### `max_artifact_files: u32` + +Maximum number of unique declared artifact files. + +### `max_artifact_file_bytes: u32` + +Maximum uncompressed bytes in one declared artifact file. + +## Trait Implementations + +### `impl Clone for ResponseLimits` + +
Clone for ResponseLimits"}} />
+ +#### `clone` + +
clone(&self) -> ResponseLimits"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ResponseLimits` + +
Debug for ResponseLimits"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for ResponseLimits` + +
Default for ResponseLimits"}} />
+ +#### `default` + +
default() -> Self"}} />
+ +### `impl<'de> Deserialize<'de> for ResponseLimitswhere ResponseLimits: Default,` + +
Deserialize<'de> for ResponseLimitswhere\n    ResponseLimits: Default,"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ResponseLimits` + +
ResponseLimits"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ResponseLimits` + +
PartialEq for ResponseLimits"}} />
+ +#### `eq` + +
eq(&self, other: &ResponseLimits) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ResponseLimits` + +
Serialize for ResponseLimits"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for ResponseLimits` + +
Copy for ResponseLimits"}} />
+ +### `impl Eq for ResponseLimits` + +
Eq for ResponseLimits"}} />
+ +### `impl StructuralPartialEq for ResponseLimits` + +
StructuralPartialEq for ResponseLimits"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx index 49fbd25e5..ea89fc52b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx @@ -2,7 +2,7 @@ title: "Struct RunPlan" sidebar-title: "RunPlan" description: "Resolved NeMo Fabric run plan." -position: 77 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx index 208e98812..cdf90245a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 78 +position: 79 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx index ad38ed633..e886828bd 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx @@ -2,14 +2,14 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Invocation runtime contract." -position: 79 +position: 80 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub output_schema: String,\n    pub artifacts: Option<PathBuf>,\n    pub timeout_seconds: Option<f64>,\n    pub max_turns: Option<u32>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub output_schema: String,\n    pub artifacts: Option<PathBuf>,\n    pub timeout_seconds: Option<f64>,\n    pub response_limits: Option<ResponseLimits>,\n    pub max_turns: Option<u32>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Invocation runtime contract. @@ -31,6 +31,10 @@ Artifact directory. Maximum duration of one invocation in seconds. +### `response_limits: Option` + +Response and declared-artifact budgets enforced by the SDK. + ### `max_turns: Option` Maximum number of harness turns within one invocation. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx index 983e0cfb0..beaf859fb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 80 +position: 81 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx index f9de3041b..d9742d216 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 81 +position: 82 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx index d0e36269d..300d27972 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 82 +position: 83 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx index d697615cd..1c8e0cecf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 84 +position: 85 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx index 7f692c549..2b6cae506 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tool Definition Config" sidebar-title: "ToolDefinitionConfig" description: "One named normalized tool or tool-group definition." -position: 85 +position: 86 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx index b162806a8..3b41dd2f2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Config" sidebar-title: "ToolsConfig" description: "Harness-neutral tool capability configuration." -position: 86 +position: 87 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx index d2b51fe8b..ffe368f07 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Plan" sidebar-title: "ToolsPlan" description: "Normalized tool policy for a run." -position: 58 +position: 59 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx index 9e8ceecec..9063b9e88 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Config" sidebar-title: "WorkflowConfig" description: "Registered workflow target and immutable construction settings." -position: 87 +position: 88 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx index 2e0f21452..0e5f8edb1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Entrypoint Config" sidebar-title: "WorkflowEntrypointConfig" description: "Adapter-owned workflow entry point supplied by an Adapter Target Descriptor." -position: 88 +position: 89 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx index b7d212906..dd15a99a3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Target Spec" sidebar-title: "WorkflowTargetSpec" description: "Workflow-specific Adapter Target Descriptor fields." -position: 89 +position: 90 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx index b96f6000a..42f3e97eb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 95 +position: 96 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx index d55dea1aa..16f0b6603 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 96 +position: 97 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index af7cd5a77..8b6c9ef29 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for NeMo Fabric." -position: 168 +position: 169 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx index 67f2f2311..44c9ea471 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 93 +position: 94 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx index 70e98b43a..1a8233a35 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 94 +position: 95 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx index 18ad7379a..52b15b93a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx @@ -2,7 +2,7 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by NeMo Fabric config loading and validation." -position: 97 +position: 98 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx index fae6ff50c..5f26fc746 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for NeMo Fabric core." -position: 169 +position: 170 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx index ba2368fdd..b2d7e90a5 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core NeMo Fabric result type." -position: 98 +position: 99 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx index 2be852fd7..e12b98201 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 175 +position: 176 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx index 9d12e7c62..d5c5ff9c0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx @@ -89,6 +89,7 @@ Core config and runtime contract for NeMo Fabric. - `pub use config::ResolveContext;` - `pub use config::ResolvedAdapterDescriptor;` - `pub use config::ResolvedAdapterTargetDescriptor;` +- `pub use config::ResponseLimits;` - `pub use config::RunPlan;` - `pub use config::RuntimeCapabilities;` - `pub use config::RuntimeConfig;` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/provenance/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/provenance/index.mdx index 302349ffe..75afb50e1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/provenance/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/provenance/index.mdx @@ -2,7 +2,7 @@ title: "Module provenance" sidebar-title: "provenance" description: "Selected software evidence. Optional evidence never controls invocation success." -position: 170 +position: 171 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx index 2f409e4b7..f95480199 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx @@ -3,7 +3,7 @@ title: "Module resource_snapshot" sidebar-title: "resource_snapshot" slug: "/reference/api/rust-library-reference/nemo-fabric-core/resource_snapshot" description: "Resource snapshots of a runtime's local adapter host and its descendants." -position: 171 +position: 172 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index d9fcf8089..d27e32d47 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 172 +position: 173 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx index de29f9470..5ed70ab8b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx @@ -87,6 +87,18 @@ NeMo Fabric lifecycle/progress events emitted during the run. Adapter-specific metadata. +## Implementations + +### `impl RunResult` + +
RunResult"}} />
+ +#### `enforce_response_limit` + +
ResponseLimits) -> Result<()>"}} />
+ +Enforce the response byte budget after the result has been finalized. A breach replaces returned payloads with a bounded limit diagnostic. + ## Trait Implementations ### `impl Clone for RunResult` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx index 5b71646c7..df698aa41 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public NeMo Fabric contract." -position: 173 +position: 174 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx index 9712ca1a6..0c984714c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx @@ -3,7 +3,7 @@ title: "Module system_stats" sidebar-title: "system_stats" slug: "/reference/api/rust-library-reference/nemo-fabric-core/system_stats" description: "Invocation measurements collected by NeMo Fabric in the executing runtime." -position: 174 +position: 175 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 3f1b1743d..03270cdfc 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -370,7 +370,7 @@ Relay subfields: | `models..top_p`, `.max_tokens` | No | No | Yes | Yes | Yes; passed through LiteLLM | | `instructions.system` | `replace`, `append` | `replace`; maps to Codex base instructions | `replace` | `replace` | `replace` | | `runtime.input_schema`, `.output_schema` | Core | Core | Core | Core | Core | -| `runtime.artifacts`, `.timeout_seconds` | Core | Core | Core | Core | Core | +| `runtime.artifacts`, `.timeout_seconds`, `.response_limits` | Core | Core | Core | Core | Core | | `runtime.max_turns` | Yes | No | Yes; maps to LangGraph supersteps | Yes; maps to Hermes iterations | Yes | | `environment.provider`, `.control_location`, `.ownership` | Core | Core | Core | Core | Core | | `environment.workspace`, `.artifacts`, `.env`, `.env_names` | Core | Core | Core | Core | Core | @@ -1419,6 +1419,75 @@ schema defaults or otherwise change the settings preserved in `RunPlan`. Validation errors identify the adapter, descriptor source and path, and offending settings path. +## Response and Artifact Limits + +`runtime.response_limits` applies to buffered invocation results on the host and +inside OpenShell. The SDK enforces these budgets independently of the harness; +adapters do not need a capability declaration for them. + +```python +from nemo_fabric import ResponseLimits + +config.runtime.response_limits = ResponseLimits( + max_response_bytes=8 * 1024 * 1024, + max_artifact_bytes=16 * 1024 * 1024, + max_artifact_files=32, + max_artifact_file_bytes=8 * 1024 * 1024, +) +``` + +The equivalent YAML belongs in the normalized runtime section: + +```yaml +runtime: + response_limits: + max_response_bytes: 8388608 + max_artifact_bytes: 16777216 + max_artifact_files: 32 + max_artifact_file_bytes: 8388608 +``` + +| Field | Default | What is counted | +| --- | --- | --- | +| `max_response_bytes` | 4 MiB | Compact UTF-8 JSON serialization of the normalized invocation `RunResult`, excluding transport envelopes and the trailing newline. Convenience runs recheck after stop events or stop errors are added. | +| `max_artifact_bytes` | 2 MiB | Sum of the uncompressed sizes of unique declared artifact files. | +| `max_artifact_files` | 16 | Unique declared artifact paths; repeated references count once. | +| `max_artifact_file_bytes` | 1 MiB | Uncompressed size of each declared artifact file. | + +Limits are inclusive: a value exactly at the budget passes. Each supplied field +must be an integer from 1 through 4294967295. Omit individual fields to keep their +defaults; omit `response_limits` or use `None` to keep all defaults. Unknown limit +names are rejected. File and aggregate byte budgets are independent, so raising +one does not raise the other. SDK diagnostics under `.fabric/`, workspace patches, +and workspace status files do not consume the declared-agent artifact budgets. +Provider transport allowances scale with these budgets; compression does not add +a separate screenshot size limit. The 32-bit schema ceiling is not a guaranteed +transfer capacity: OpenShell buffers compressed artifacts as JSON byte arrays, +which increase wire size and memory use, and collection retains a separate +30-second deadline. Raising byte budgets does not increase sandbox memory or +that deadline. Large batches can still fail with `gateway_timeout` or +`artifact_read_failed`; split them into smaller batches or return references to +externally stored data when they exceed the sandbox's transfer capacity. + +A breach returns `status == "failed"` with +`error.code == "response_limit_exceeded"`. The error names the limit, configured +value, observed value and offending asset in both its message and metadata. +The rejected output and artifact manifest are replaced by this diagnostic; +there is no partial successful result or silent truncation. Only the SDK's minimal canonical limit diagnostic is exempt from the response +budget, so even a one-byte budget reports why it failed. Additional payloads or +shutdown metadata are checked normally. A limit failure does not undo agent side effects and is not automatically +retried. + +For a large screenshot, keep the image in a declared artifact instead of embedding +base64 in the response. Raise both `max_artifact_file_bytes` and +`max_artifact_bytes` enough to hold the complete file and the other artifacts, +then invoke again. On success, read its returned `ArtifactRef.path`; OpenShell +copies the complete bytes into the host `runtime.artifacts` destination. Local +execution keeps the original file path. A result that exceeds its response +budget must also fit after artifact paths and execution metadata are added on +the host. These are returned-result budgets, not limits on intermediate model +messages or streaming chunks already delivered to a consumer. + ## Errors All public SDK errors inherit from `FabricError`. diff --git a/environment-providers/openshell/src/lib.rs b/environment-providers/openshell/src/lib.rs index fe5691e11..03f1af7f0 100644 --- a/environment-providers/openshell/src/lib.rs +++ b/environment-providers/openshell/src/lib.rs @@ -36,10 +36,8 @@ const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 60; const DEFAULT_DELETE_TIMEOUT_SECONDS: u64 = 30; // Compressed decimal JSON byte arrays stay below the 3 MiB exec-output limit. // Expansion is separately bounded before bytes cross the provider protocol. -const MAX_COMPRESSED_ARTIFACT_FILE_BYTES: usize = 256 * 1024; -const MAX_TOTAL_COMPRESSED_ARTIFACT_BYTES: usize = 700 * 1024; +#[cfg(test)] const MAX_ARTIFACT_FILE_BYTES: usize = 1024 * 1024; -const MAX_TOTAL_ARTIFACT_BYTES: usize = 2 * 1024 * 1024; const DEFAULT_EXEC_TIMEOUT_SECONDS: u64 = 30; const GATEWAY_CALL_TIMEOUT_SECONDS: u64 = 30; const MAX_OPERATION_TIMEOUT_SECONDS: u64 = 600; @@ -59,6 +57,20 @@ pub enum ProviderError { /// Standard input or output failed. #[error("provider I/O failed: {0}")] Io(#[from] std::io::Error), + /// A declared artifact exceeded a host-configured budget. + #[error( + "runtime.response_limits.{limit}={configured} exceeded by {asset:?}: observed {observed}" + )] + ArtifactLimit { + /// Normalized limit name. + limit: String, + /// Host-configured budget. + configured: usize, + /// Observed size or count. + observed: usize, + /// Correlated declared artifact path. + asset: String, + }, /// The request violated the provider contract. #[error("{message}")] Contract { @@ -80,6 +92,7 @@ impl ProviderError { fn code(&self) -> &str { match self { Self::Io(_) => "provider_io", + Self::ArtifactLimit { .. } => "response_limit_exceeded", Self::Contract { code, .. } => code, } } @@ -212,10 +225,32 @@ where environment, command, timeout_seconds, - } => runtime_command(environment, command, timeout_seconds, factory).await, - ProviderOperation::CollectArtifacts { environment, paths } => { - collect_artifacts(environment, paths, factory).await + response_limit_bytes, + } => { + runtime_command_with_limit( + environment, + command, + timeout_seconds, + response_limit_bytes, + factory, + ) + .await } + ProviderOperation::CollectArtifacts { + environment, + paths, + limits, + } => match collect_artifacts(environment, paths, limits, factory).await { + Err(ProviderError::ArtifactLimit { + limit, + configured, + observed, + asset, + }) => Ok( + json!({"limit": limit, "configured": configured, "observed": observed, "asset": asset}), + ), + other => other, + }, ProviderOperation::Prepare { environment_id, environment, @@ -253,11 +288,29 @@ where } } +#[cfg(test)] async fn runtime_command( environment: EnvironmentHandle, command: TaskCommand, timeout_seconds: impl Into>, factory: &F, +) -> Result { + runtime_command_with_limit( + environment, + command, + timeout_seconds, + 4 * 1024 * 1024, + factory, + ) + .await +} + +async fn runtime_command_with_limit( + environment: EnvironmentHandle, + command: TaskCommand, + timeout_seconds: impl Into>, + response_limit_bytes: usize, + factory: &F, ) -> Result { let timeout_seconds = timeout_seconds.into(); if timeout_seconds == Some(0) @@ -338,14 +391,17 @@ async fn runtime_command( arguments, ExecRequest { workdir: None, - environment: HashMap::new(), + environment: HashMap::from([( + "FABRIC_RESPONSE_LIMIT_BYTES".into(), + response_limit_bytes.to_string(), + )]), timeout: exec_timeout, stdin, }, ), ) .await?; - if executed.stdout.len().saturating_add(executed.stderr.len()) > 4 * 1024 * 1024 { + if executed.stdout.len().saturating_add(executed.stderr.len()) > response_limit_bytes { return Err(ProviderError::contract( "response_too_large", "task command output exceeded its limit; effects are unknown", @@ -564,26 +620,114 @@ fn valid_diagnostic_code(code: &str) -> bool { ) } +fn default_response_limit_bytes() -> usize { + 4 * 1024 * 1024 +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(default)] +struct ArtifactLimits { + max_response_bytes: u32, + max_artifact_bytes: u32, + max_artifact_files: u32, + max_artifact_file_bytes: u32, +} +impl Default for ArtifactLimits { + fn default() -> Self { + Self { + max_response_bytes: 4 * 1024 * 1024, + max_artifact_bytes: 2 * 1024 * 1024, + max_artifact_files: 16, + max_artifact_file_bytes: 1024 * 1024, + } + } +} +impl ArtifactLimits { + fn transport_bytes(self) -> usize { + (self.max_artifact_bytes as usize) + .saturating_mul(8) + .saturating_add((self.max_response_bytes as usize).saturating_mul(6)) + .saturating_add(1024 * 1024) + } +} +#[derive(Deserialize)] +struct ArtifactLimitFailure { + limit: String, + observed: usize, + asset: String, +} +fn artifact_limit_error( + limit: &str, + configured: usize, + observed: usize, + asset: &str, +) -> ProviderError { + ProviderError::ArtifactLimit { + limit: limit.into(), + configured, + observed, + asset: asset.into(), + } +} + +fn validated_artifact_limit_failure( + stdout: &[u8], + paths: &[String], + limits: ArtifactLimits, +) -> Option { + let error: ArtifactLimitFailure = serde_json::from_slice(stdout).ok()?; + let configured = match error.limit.as_str() { + "max_artifact_bytes" => limits.max_artifact_bytes, + "max_artifact_file_bytes" => limits.max_artifact_file_bytes, + "max_artifact_files" => limits.max_artifact_files, + _ => return None, + } as usize; + if !paths.contains(&error.asset) + || error.asset.chars().any(char::is_control) + || error.asset.len() > 4096 + || error.observed <= configured + { + return None; + } + Some(artifact_limit_error( + &error.limit, + configured, + error.observed, + &error.asset, + )) +} + async fn collect_artifacts( environment: EnvironmentHandle, paths: Vec, + limits: ArtifactLimits, factory: &F, ) -> Result { - if paths.len() > 16 - || paths.iter().any(|path| { - path.is_empty() - || path.contains('\\') - || path.contains('\0') - || path - .split('/') - .any(|part| part.is_empty() || part == "." || part == "..") - }) - { + if paths.iter().any(|path| { + path.is_empty() + || path.contains('\\') + || path.chars().any(char::is_control) + || path.len() > 4096 + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + }) { return Err(ProviderError::contract( "invalid_artifact_path", "declared artifact paths are invalid", )); } + if paths.len() > limits.max_artifact_files as usize { + return Err(artifact_limit_error( + "max_artifact_files", + limits.max_artifact_files as usize, + paths.len(), + paths + .last() + .map(String::as_str) + .unwrap_or("artifact manifest"), + )); + } let root = environment .artifacts .clone() @@ -608,12 +752,13 @@ async fn collect_artifacts( ) .await?; binding.verify(&sandbox)?; - let stdin = serde_json::to_vec(&json!({"root": root, "paths": paths})).map_err(|_| { - ProviderError::contract( - "invalid_artifact_path", - "artifact request could not be encoded", - ) - })?; + let stdin = serde_json::to_vec(&json!({"root": root, "paths": paths, "limits": limits})) + .map_err(|_| { + ProviderError::contract( + "invalid_artifact_path", + "artifact request could not be encoded", + ) + })?; let executed = bounded_gateway_call( "exec", Duration::from_secs(30), @@ -634,15 +779,22 @@ async fn collect_artifacts( ), ) .await?; - if executed.exit_code != 0 - || executed.stdout.len().saturating_add(executed.stderr.len()) > MAX_EXEC_OUTPUT_BYTES - { + if executed.stdout.len().saturating_add(executed.stderr.len()) > limits.transport_bytes() { + return Err(ProviderError::contract( + "artifact_read_failed", + "artifact transfer exceeded its derived transport budget", + )); + } + if executed.exit_code != 0 { + if let Some(error) = validated_artifact_limit_failure(&executed.stdout, &paths, limits) { + return Err(error); + } return Err(ProviderError::contract( "artifact_read_failed", "declared artifacts could not be read as bounded regular files", )); } - let files = decode_artifact_response(&executed.stdout, &paths)?; + let files = decode_artifact_response_with_limits(&executed.stdout, &paths, limits)?; serde_json::to_value(files).map_err(|_| { ProviderError::contract( "artifact_read_failed", @@ -651,9 +803,18 @@ async fn collect_artifacts( }) } +#[cfg(test)] fn decode_artifact_response( stdout: &[u8], paths: &[String], +) -> Result, ProviderError> { + decode_artifact_response_with_limits(stdout, paths, ArtifactLimits::default()) +} + +fn decode_artifact_response_with_limits( + stdout: &[u8], + paths: &[String], + limits: ArtifactLimits, ) -> Result, ProviderError> { let response: ArtifactResponse = serde_json::from_slice(stdout).map_err(|_| { ProviderError::contract( @@ -673,24 +834,38 @@ fn decode_artifact_response( for (file, path) in response.files.into_iter().zip(paths) { compressed_total = compressed_total.saturating_add(file.content.len()); uncompressed_total = uncompressed_total.saturating_add(file.uncompressed_size); - if file.path != path.as_str() - || file.content.len() > MAX_COMPRESSED_ARTIFACT_FILE_BYTES - || compressed_total > MAX_TOTAL_COMPRESSED_ARTIFACT_BYTES - || file.uncompressed_size > MAX_ARTIFACT_FILE_BYTES - || uncompressed_total > MAX_TOTAL_ARTIFACT_BYTES - { + if file.path != path.as_str() || compressed_total > limits.transport_bytes() { return Err(ProviderError::contract( "artifact_read_failed", "artifact reader failed correlation or size checks", )); } - let content = decompress_to_vec_zlib_with_limit(&file.content, MAX_ARTIFACT_FILE_BYTES) - .map_err(|_| { - ProviderError::contract( - "artifact_read_failed", - "artifact reader returned invalid compressed bytes", - ) - })?; + if file.uncompressed_size > limits.max_artifact_file_bytes as usize { + return Err(artifact_limit_error( + "max_artifact_file_bytes", + limits.max_artifact_file_bytes as usize, + file.uncompressed_size, + path, + )); + } + if uncompressed_total > limits.max_artifact_bytes as usize { + return Err(artifact_limit_error( + "max_artifact_bytes", + limits.max_artifact_bytes as usize, + uncompressed_total, + path, + )); + } + let content = decompress_to_vec_zlib_with_limit( + &file.content, + limits.max_artifact_file_bytes as usize, + ) + .map_err(|_| { + ProviderError::contract( + "artifact_read_failed", + "artifact reader returned invalid compressed bytes", + ) + })?; if content.len() != file.uncompressed_size { return Err(ProviderError::contract( "artifact_read_failed", @@ -1337,10 +1512,14 @@ enum ProviderOperation { environment: EnvironmentHandle, command: TaskCommand, timeout_seconds: Option, + #[serde(default = "default_response_limit_bytes")] + response_limit_bytes: usize, }, CollectArtifacts { environment: EnvironmentHandle, paths: Vec, + #[serde(default)] + limits: ArtifactLimits, }, Prepare { environment_id: String, @@ -2511,6 +2690,87 @@ mod tests { } } + #[tokio::test] + async fn artifact_limit_observations_cross_the_provider_boundary_as_structured_data() { + let limits = ArtifactLimits::default(); + for asset in ["screenshot.png", "undeclared.png"] { + let factory = MockFactory { + calls: Arc::new(Mutex::new(Vec::new())), + ready_id: "sandbox-id-1", + deletion: None, + execution: Some(( + 1, + serde_json::to_vec( + &json!({"limit": "max_artifact_file_bytes", "configured": 1, + "observed": limits.max_artifact_file_bytes + 1, "asset": asset}), + ) + .unwrap(), + Vec::new(), + )), + }; + let outcome = handle_operation( + ProviderOperation::CollectArtifacts { + environment: environment_handle_fixture(), + paths: vec!["screenshot.png".into()], + limits, + }, + &factory, + ) + .await; + if asset == "screenshot.png" { + assert_eq!( + outcome.unwrap(), + json!({"limit": "max_artifact_file_bytes", "configured": limits.max_artifact_file_bytes, + "observed": limits.max_artifact_file_bytes + 1, "asset": asset}) + ); + } else { + assert_eq!(outcome.unwrap_err().code(), "artifact_read_failed"); + } + } + } + + #[test] + fn sandbox_limit_diagnostics_use_host_values_and_declared_assets() { + let paths = vec!["screenshot.png".to_string()]; + let limits = ArtifactLimits::default(); + let valid = json!({"limit": "max_artifact_file_bytes", "configured": 1, + "observed": limits.max_artifact_file_bytes + 1, "asset": paths[0]}); + let error = + validated_artifact_limit_failure(&serde_json::to_vec(&valid).unwrap(), &paths, limits) + .unwrap(); + match error { + ProviderError::ArtifactLimit { + configured, + observed, + asset, + .. + } => { + assert_eq!(configured, limits.max_artifact_file_bytes as usize); + assert_eq!(observed, configured + 1); + assert_eq!(asset, paths[0]); + } + other => panic!("unexpected error: {other}"), + } + for (field, value) in [ + ("asset", json!("other.png")), + ("asset", json!("screenshot.png\nPRIVATE")), + ("asset", json!("x".repeat(8192))), + ("limit", json!("unknown")), + ("observed", json!(0)), + ] { + let mut invalid = valid.clone(); + invalid[field] = value; + assert!( + validated_artifact_limit_failure( + &serde_json::to_vec(&invalid).unwrap(), + &paths, + limits + ) + .is_none() + ); + } + } + #[test] fn artifact_response_expands_large_relay_file_with_a_bound() { let content = vec![b'x'; 687_836]; @@ -2535,7 +2795,9 @@ mod tests { }]})) .expect("oversized response"); let error = decode_artifact_response(&response, &paths).expect_err("reject expansion"); - assert_eq!(error.code(), "artifact_read_failed"); + assert_eq!(error.code(), "response_limit_exceeded"); + assert!(error.to_string().contains("max_artifact_file_bytes")); + assert!(error.to_string().contains(&paths[0])); } #[tokio::test] @@ -2715,6 +2977,33 @@ mod tests { assert_eq!(settings.policy.expect("policy").version, 1); } + #[test] + fn response_limits_do_not_change_existing_profile_path_validation() { + for path in [ + "/sandbox/with\nnewline".to_string(), + format!("/sandbox/{}", "a".repeat(4096)), + ] { + let mut environment = environment_plan_fixture(); + environment.workspace = Some(path.clone()); + environment.artifacts = Some(path); + validate_profile(&environment, "fabric_owned").expect("unchanged profile validation"); + } + } + + #[test] + fn artifact_transport_budget_saturates_instead_of_wrapping() { + let limits = ArtifactLimits { + max_response_bytes: u32::MAX, + max_artifact_bytes: u32::MAX, + ..Default::default() + }; + let expected = u128::from(u32::MAX) * 14 + 1024 * 1024; + assert_eq!( + limits.transport_bytes(), + expected.min(usize::MAX as u128) as usize + ); + } + #[test] fn openshell_profile_requires_a_sandbox_workspace() { let mut environment = environment_plan_fixture(); diff --git a/environment-providers/openshell/src/read_artifacts.py b/environment-providers/openshell/src/read_artifacts.py index bde6759ec..6597e83ab 100644 --- a/environment-providers/openshell/src/read_artifacts.py +++ b/environment-providers/openshell/src/read_artifacts.py @@ -5,8 +5,8 @@ The provider runs this one-shot script with the task interpreter, passing an absolute artifact root and relative file paths as JSON on stdin. It reads only -regular files beneath that root without following symlinks, with limits of 16 -files, 1 MiB per file, and 2 MiB total. It compresses each file for the bounded +regular files beneath that root without following symlinks, using the normalized +runtime response limits supplied by the host. It compresses each file for the bounded JSON transfer; the host verifies and expands the bytes before copying them to the artifact destination. The persistent task runtime server is not involved. """ @@ -19,11 +19,17 @@ FILE_LIMIT = 1024 * 1024 TOTAL_LIMIT = 2 * 1024 * 1024 -TRANSFER_FILE_LIMIT = 256 * 1024 -TRANSFER_TOTAL_LIMIT = 700 * 1024 COUNT_LIMIT = 16 +class LimitExceeded(ValueError): + def __init__(self, limit, configured, observed, asset): + self.details = dict( + limit=limit, configured=configured, observed=observed, asset=asset + ) + super().__init__(limit) + + def directory(parent, name): return os.open( name, @@ -37,8 +43,19 @@ def collect(request): paths = request["paths"] if not isinstance(root, str) or not root.startswith("/") or ".." in root.split("/"): raise ValueError("invalid root") - if not isinstance(paths, list) or len(paths) > COUNT_LIMIT: - raise ValueError("invalid file count") + limits = request.get("limits", {}) + file_limit = limits.get("max_artifact_file_bytes", FILE_LIMIT) + total_limit = limits.get("max_artifact_bytes", TOTAL_LIMIT) + count_limit = limits.get("max_artifact_files", COUNT_LIMIT) + if any( + type(value) is not int or not 0 < value <= (1 << 32) - 1 + for value in (file_limit, total_limit, count_limit) + ): + raise ValueError("invalid limits") + if not isinstance(paths, list): + raise ValueError("invalid file list") + if len(paths) > count_limit: + raise LimitExceeded("max_artifact_files", count_limit, len(paths), paths[-1]) root_descriptor = directory(None, "/") try: for name in root.split("/"): @@ -49,7 +66,6 @@ def collect(request): root_descriptor = child files = [] total = 0 - transferred = 0 for path in paths: if not isinstance(path, str) or not path or "\\" in path or "\0" in path: raise ValueError("invalid path") @@ -69,24 +85,42 @@ def collect(request): ) with os.fdopen(descriptor, "rb") as stream: before = os.fstat(stream.fileno()) - if not stat.S_ISREG(before.st_mode) or before.st_size > FILE_LIMIT: - raise ValueError("not a bounded regular file") - content = stream.read(FILE_LIMIT + 1) + if not stat.S_ISREG(before.st_mode): + raise ValueError("not a regular file") + if before.st_size > file_limit: + raise LimitExceeded( + "max_artifact_file_bytes", file_limit, before.st_size, path + ) + if total + before.st_size > total_limit: + raise LimitExceeded( + "max_artifact_bytes", + total_limit, + total + before.st_size, + path, + ) + content = stream.read(file_limit + 1) after = os.fstat(stream.fileno()) - if len(content) > FILE_LIMIT or ( + if len(content) > file_limit: + raise LimitExceeded( + "max_artifact_file_bytes", file_limit, len(content), path + ) + if ( before.st_size, before.st_mtime_ns, ) != (after.st_size, after.st_mtime_ns): raise ValueError("file changed or exceeded limit") total += len(content) - if total > TOTAL_LIMIT: - raise ValueError("total exceeded limit") + if total > total_limit: + raise LimitExceeded( + "max_artifact_bytes", total_limit, total, path + ) compressed = zlib.compress(content) - transferred += len(compressed) - if len(compressed) > TRANSFER_FILE_LIMIT or transferred > TRANSFER_TOTAL_LIMIT: - raise ValueError("compressed transfer exceeded limit") files.append( - {"path": path, "content": list(compressed), "uncompressed_size": len(content)} + { + "path": path, + "content": list(compressed), + "uncompressed_size": len(content), + } ) finally: os.close(parent) @@ -96,8 +130,11 @@ def collect(request): try: - request = json.loads(sys.stdin.buffer.read(16 * 1024 + 1)) + request = json.loads(sys.stdin.buffer.read(1024 * 1024 + 1)) print(json.dumps({"files": collect(request)}, separators=(",", ":"))) +except LimitExceeded as error: + print(json.dumps(error.details, separators=(",", ":"))) + sys.exit(1) except Exception: # Never emit submitted paths, file data, or raw OS errors in diagnostics. print('{"error":"artifact_read_failed"}') diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index 0bfc5ff57..c1c808b3e 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -475,3 +475,11 @@ address (for example, `10.0.0.10/32`). A local inference URL that uses an IP literal cannot be used directly through this OpenShell policy path. The [custom LangGraph example](../langgraph_custom_agent/README.md#run-the-custom-agent-in-openshell) uses the same SDK and launcher with an application-owned agent and dedicated adapter. + +### Response and artifact budgets + +The example's `config.runtime.response_limits` accepts the normalized +[`ResponseLimits` configuration](../../docs/sdk/python.mdx#response-and-artifact-limits) +for every harness, on the host or inside OpenShell. Increase the per-file and +aggregate artifact byte budgets together when returning large screenshots. +Check `result.status` and `result.error` before consuming output or artifact paths. diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index b42206626..16c09901e 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -197,3 +197,13 @@ unchanged. `sdk/run-result.execution` is optional. `sdk/agent.provenance` controls collection and carries explicit caller reports through existing dispatch. Optional evidence is decoded defensively; invalid reports become coverage gaps. The strict `AgentConfig`, `AgentRunRequest`, `AgentRunResult` and `RuntimeContext` shapes and adapter wire version are unchanged. Evidence receivers additionally enforce report byte/aggregate entry budgets, unique context keys and infrastructure references to existing contexts. These cross-field limits supplement the JSON Schema's closed shapes, non-blank identities, observation and coverage constraints. + +### Response budgets + +The SDK configuration and run-plan schemas include `RuntimeConfig.response_limits` +and the `ResponseLimits` definition. The four positive integer fields are +`max_response_bytes`, `max_artifact_bytes`, `max_artifact_files`, and +`max_artifact_file_bytes`; omitted fields use their declared defaults. These are +SDK-owned budgets, so they do not change `AgentRuntimeConfig` or the southbound +adapter contract. See the [SDK guide](../docs/sdk/python.mdx#response-and-artifact-limits) +for counting rules, defaults, and failure behavior. diff --git a/schemas/sdk/agent.schema.json b/schemas/sdk/agent.schema.json index 63bbcf0d6..fd36646c7 100644 --- a/schemas/sdk/agent.schema.json +++ b/schemas/sdk/agent.schema.json @@ -1479,6 +1479,41 @@ } ] }, + "ResponseLimits": { + "additionalProperties": false, + "description": "Inclusive budgets for a buffered invocation result and declared artifact files.\nOmitted fields retain the default budget. These limits are owned by the SDK,\nindependently of the harness and execution environment.", + "properties": { + "max_artifact_bytes": { + "default": 2097152, + "description": "Maximum aggregate uncompressed bytes in unique declared artifact files.", + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "max_artifact_file_bytes": { + "default": 1048576, + "description": "Maximum uncompressed bytes in one declared artifact file.", + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "max_artifact_files": { + "default": 16, + "description": "Maximum number of unique declared artifact files.", + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "max_response_bytes": { + "default": 4194304, + "description": "Maximum compact UTF-8 JSON bytes in the normalized RunResult.", + "format": "uint32", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, "RuntimeConfig": { "additionalProperties": true, "description": "Invocation runtime contract.", @@ -1510,6 +1545,17 @@ "description": "Output schema label.", "type": "string" }, + "response_limits": { + "anyOf": [ + { + "$ref": "#/$defs/ResponseLimits" + }, + { + "type": "null" + } + ], + "description": "Response and declared-artifact budgets enforced by the SDK." + }, "timeout_seconds": { "description": "Maximum duration of one invocation in seconds.", "exclusiveMinimum": 0.0, diff --git a/schemas/sdk/run-plan.schema.json b/schemas/sdk/run-plan.schema.json index a15e3e678..1fb376bc1 100644 --- a/schemas/sdk/run-plan.schema.json +++ b/schemas/sdk/run-plan.schema.json @@ -2917,6 +2917,41 @@ ], "type": "object" }, + "ResponseLimits": { + "additionalProperties": false, + "description": "Inclusive budgets for a buffered invocation result and declared artifact files.\nOmitted fields retain the default budget. These limits are owned by the SDK,\nindependently of the harness and execution environment.", + "properties": { + "max_artifact_bytes": { + "default": 2097152, + "description": "Maximum aggregate uncompressed bytes in unique declared artifact files.", + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "max_artifact_file_bytes": { + "default": 1048576, + "description": "Maximum uncompressed bytes in one declared artifact file.", + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "max_artifact_files": { + "default": 16, + "description": "Maximum number of unique declared artifact files.", + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "max_response_bytes": { + "default": 4194304, + "description": "Maximum compact UTF-8 JSON bytes in the normalized RunResult.", + "format": "uint32", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, "RuntimeCapabilities": { "description": "Lifecycle behavior implemented by a resolved runtime path.", "properties": { @@ -2979,6 +3014,17 @@ "description": "Output schema label.", "type": "string" }, + "response_limits": { + "anyOf": [ + { + "$ref": "#/$defs/ResponseLimits" + }, + { + "type": "null" + } + ], + "description": "Response and declared-artifact budgets enforced by the SDK." + }, "timeout_seconds": { "description": "Maximum duration of one invocation in seconds.", "exclusiveMinimum": 0.0, diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py index 78050ab22..cc4b8600d 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py @@ -43,6 +43,7 @@ from nemo_fabric.models import RunRequest from nemo_fabric.models import SystemStatsConfig from nemo_fabric.models import SystemStatsSamplingConfig +from nemo_fabric.models import ResponseLimits from nemo_fabric.models import RuntimeConfig from nemo_fabric.models import SkillConfig from nemo_fabric.models import TelemetryConfig @@ -160,6 +161,7 @@ "SystemStatsConfig", "SystemStatsSamplingConfig", "RuntimeConfig", + "ResponseLimits", "Runtime", "RuntimeStatus", "Service", diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi index 370bb5813..393cd889f 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi @@ -50,3 +50,5 @@ def check_service(service_json: str, request_json: str) -> str: ... class ResourceObservationUnavailableError(RuntimeError): ... class ResourceObservationUnsupportedError(RuntimeError): ... + +def finalize_run_result(result_json: str, max_response_bytes: int) -> str: ... diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py index 184f2a5b5..d6f1da7db 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py @@ -189,6 +189,23 @@ class InstructionsConfig(FabricBaseModel): system: InstructionConfig | None = None +class ResponseLimits(FabricBaseModel): + """Inclusive byte and file budgets, enforced by the SDK in every environment.""" + + model_config = ConfigDict(extra="forbid") + + max_response_bytes: int = Field( + default=4 * 1024 * 1024, strict=True, gt=0, le=(1 << 32) - 1 + ) + max_artifact_bytes: int = Field( + default=2 * 1024 * 1024, strict=True, gt=0, le=(1 << 32) - 1 + ) + max_artifact_files: int = Field(default=16, strict=True, gt=0, le=(1 << 32) - 1) + max_artifact_file_bytes: int = Field( + default=1024 * 1024, strict=True, gt=0, le=(1 << 32) - 1 + ) + + class RuntimeConfig(FabricBaseModel): """Invocation runtime contract.""" @@ -201,6 +218,7 @@ class RuntimeConfig(FabricBaseModel): allow_inf_nan=False, ) max_turns: int | None = Field(default=None, gt=0, le=(1 << 32) - 1) + response_limits: ResponseLimits | None = None class EnvironmentConfig(FabricBaseModel): diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py index f29e0bbad..8dbcea8ab 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py @@ -1003,6 +1003,7 @@ def run() -> dict[str, Any]: raise invoke_error from error return result finally: + result_changed = False try: stop_events = json.loads(native.stop_runtime(plan_json, runtime_json)) except Exception as error: @@ -1010,6 +1011,7 @@ def run() -> dict[str, Any]: if result is None: raise if result.get("status") == "succeeded": + result_changed = True result["status"] = "failed" result["error"] = { "stage": "stop", @@ -1019,8 +1021,22 @@ def run() -> dict[str, Any]: "retryable": False, } stop_events = [] - if result is not None and isinstance(stop_events, list): + if result is not None and isinstance(stop_events, list) and stop_events: result.setdefault("events", []).extend(stop_events) + result_changed = True + if result is not None and result_changed: + limits = ( + plan.get("config", {}).get("runtime", {}).get("response_limits") + or {} + ) + finalized = json.loads( + native.finalize_run_result( + json.dumps(result), + limits.get("max_response_bytes", 4 * 1024 * 1024), + ) + ) + result.clear() + result.update(finalized) try: return await _call_blocking(run) diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/cli.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/cli.py index 7f55a3e03..5855d795c 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/cli.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/cli.py @@ -24,6 +24,7 @@ REQUEST_LIMIT, SOCKET_PATH, ProtocolError, + response_transport_limit, decode_json, decode_object, encode_request, @@ -153,7 +154,13 @@ def parse_command(arguments: list[str]) -> dict[str, Any]: def main(arguments: list[str] | None = None) -> int: arguments = sys.argv[1:] if arguments is None else arguments operation = arguments[0] if arguments and arguments[0] in OPERATIONS else "unknown" + response_limit = response_transport_limit() try: + response_limit = int( + os.environ.get("FABRIC_RESPONSE_LIMIT_BYTES", response_limit) + ) + if response_limit <= 0: + raise ProtocolError() request = parse_command(arguments) if transport.fcntl is None: raise ProtocolError("platform_unsupported", "transport", unsupported=True) @@ -163,7 +170,11 @@ def main(arguments: list[str] | None = None) -> int: host = RuntimeServer(directory=socket_path.parent) asyncio.run(transport.serve(host, socket_path=socket_path)) return 0 - response = asyncio.run(transport.client(request, socket_path=socket_path)) + response = asyncio.run( + transport.client( + request, socket_path=socket_path, response_limit=response_limit + ) + ) except Exception as error: if operation == "serve": print( @@ -182,6 +193,6 @@ def main(arguments: list[str] | None = None) -> int: if not isinstance(error, ProtocolError): error = ProtocolError() response = envelope(operation, error=error) - encoded = encode_response(response) + encoded = encode_response(response, limit=response_limit) sys.stdout.write(encoded.decode("utf-8")) return 0 if decode_object(encoded)["status"] == "succeeded" else 1 diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/protocol.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/protocol.py index 8a595de9b..ff14edf53 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/protocol.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/protocol.py @@ -13,6 +13,13 @@ INTERFACE_VERSION = 2 REQUEST_LIMIT = 512 * 1024 RESPONSE_LIMIT = 4 * 1024 * 1024 + + +def response_transport_limit(max_response_bytes: int = RESPONSE_LIMIT) -> int: + """Match the native core allowance; provider calls forward that computed value.""" + return max_response_bytes * 6 + 1024 * 1024 + + CALL_SECONDS = 320.0 READ_SECONDS = 10.0 SHUTDOWN_SECONDS = 4.0 @@ -216,10 +223,10 @@ def encode_request(request: dict[str, Any]) -> bytes: return encoded -def encode_response(response: dict[str, Any]) -> bytes: +def encode_response(response: dict[str, Any], *, limit: int = RESPONSE_LIMIT) -> bytes: try: encoded = _encode(response) - if len(encoded) <= RESPONSE_LIMIT: + if len(encoded) <= limit: return encoded except (ValueError, TypeError, RecursionError): pass diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/state.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/state.py index 2df6670f8..02ec24101 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/state.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/state.py @@ -26,6 +26,7 @@ from nemo_fabric.runtime_server.protocol import ( OPERATIONS, ProtocolError, + response_transport_limit, envelope, validate_request, ) @@ -45,6 +46,7 @@ class OwnedRuntime: state: str = "running" lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) generation: int = 0 + response_limit_bytes: int = response_transport_limit() class RuntimeServer: @@ -66,6 +68,15 @@ def __init__( self.start_uncertain = False self._start_lock = asyncio.Lock() + def response_limit_bytes(self, request: dict[str, Any]) -> int: + runtime_id = request.get("runtime_id") + owned = self.runtimes.get(runtime_id) if isinstance(runtime_id, str) else None + return ( + owned.response_limit_bytes + if owned is not None + else response_transport_limit() + ) + def _owned(self, request: dict[str, Any]) -> OwnedRuntime: owned = self.runtimes.get(request["runtime_id"]) if owned is None: @@ -155,6 +166,10 @@ async def handle(self, request: Any) -> dict[str, Any]: # uncertain and retain sandbox ownership. raise ProtocolError("runtime_id_conflict", "start") owned = OwnedRuntime(runtime, request["agent"], "unknown") + if config.runtime.response_limits is not None: + owned.response_limit_bytes = response_transport_limit( + config.runtime.response_limits.max_response_bytes + ) self.runtimes[runtime.runtime_id] = owned # The runtime is addressable; stop can clean it up. self.start_uncertain = False diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/transport.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/transport.py index 9d398ff58..9fedf2d45 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/transport.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime_server/transport.py @@ -28,6 +28,7 @@ SHUTDOWN_SECONDS, SOCKET_PATH, ProtocolError, + response_transport_limit, decode_object, encode_request, encode_response, @@ -89,6 +90,7 @@ async def client( *, socket_path: Path = Path(SOCKET_PATH), timeout: float | None = None, + response_limit: int = response_transport_limit(), ) -> dict[str, Any]: started = time.monotonic() operation = request["operation"] @@ -116,7 +118,7 @@ async def client( else None ): reader, writer = await asyncio.open_unix_connection( - socket_path, limit=RESPONSE_LIMIT + socket_path, limit=response_limit ) if operation == "check": remaining = deadline - time.monotonic() @@ -126,7 +128,7 @@ async def client( writer.write(encoded) sent = True await writer.drain() - response = await read_message(reader, RESPONSE_LIMIT) + response = await read_message(reader, response_limit) validate_response(response, operation) if operation == "check": finished = time.monotonic() @@ -249,10 +251,12 @@ async def handle( async with asyncio.timeout(READ_SECONDS): request = await read_message(reader, REQUEST_LIMIT) response = await host.handle(request) + response_limit = host.response_limit_bytes(request) except (ValueError, TimeoutError): response = envelope("unknown", error=ProtocolError()) + response_limit = RESPONSE_LIMIT # A disconnected bridge does not cancel or replay runtime work. - writer.write(encode_response(response)) + writer.write(encode_response(response, limit=response_limit)) await asyncio.wait_for(writer.drain(), READ_SECONDS) except (OSError, TimeoutError): pass diff --git a/skills/nemo-fabric-build-adapter/SKILL.md b/skills/nemo-fabric-build-adapter/SKILL.md index 9dfc2389c..f31dca726 100644 --- a/skills/nemo-fabric-build-adapter/SKILL.md +++ b/skills/nemo-fabric-build-adapter/SKILL.md @@ -139,6 +139,9 @@ one `stop` for each NeMo Fabric runtime. - Accept `AgentRunRequest` and `RuntimeContext`, then return one `AgentRunResult` from `invoke`. - Make `stop` safe after partial startup and failed invocation. +- Leave `runtime.response_limits` enforcement to the SDK. These budgets are + independent of harness capabilities and are not projected into `AgentRuntimeConfig`. + Return complete outputs and declare complete artifact files; do not silently truncate. - Isolate mutable state between independent runtimes. - If the descriptor declares `capabilities.streaming`, implement `async invoke_openai_stream(request, context, emit)`. Execute the target exactly once, diff --git a/skills/nemo-fabric-integrate/SKILL.md b/skills/nemo-fabric-integrate/SKILL.md index c24c7cc58..9f574d677 100644 --- a/skills/nemo-fabric-integrate/SKILL.md +++ b/skills/nemo-fabric-integrate/SKILL.md @@ -171,6 +171,11 @@ shows this pattern end to end with complete Hermes Agent, Codex, Deep Agents, environment, MCP, and telemetry variants. Reuse it rather than duplicating config construction. +`runtime.response_limits` is SDK-owned and applies equally to host and OpenShell +execution. Author it with `ResponseLimits`; omitted fields retain defaults. +Check for failed results with `error.code == "response_limit_exceeded"` before +reading output or artifact paths. See the [SDK counting rules](https://github.com/NVIDIA/NeMo-Fabric/blob/main/docs/sdk/python.mdx#response-and-artifact-limits). + ## Choose A Lifecycle For Deep Agents, mini-SWE-agent, and the LangGraph custom-agent example, pass the same UUID string through `RunRequest.relay_session_root` on each conversation turn to group Relay trajectories under one session. Core forwards the typed field as `AgentRunRequest.relay_session_root`; context keys do not control Relay propagation. An unusable UUID preserves per-request behavior. The remaining adapters do not consume this field. diff --git a/tests/e2e/Dockerfile.response-limits b/tests/e2e/Dockerfile.response-limits new file mode 100644 index 000000000..90f312546 --- /dev/null +++ b/tests/e2e/Dockerfile.response-limits @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM fabric-openshell-sdk:source +USER root +RUN install -d -o sandbox -g sandbox /opt/fabric/agents/limits +COPY --chown=sandbox:sandbox tests/e2e/response_limits_adapter.py tests/e2e/response-limits.fabric-adapter.json /opt/fabric/agents/limits/ +USER sandbox:sandbox +WORKDIR /sandbox/workspace diff --git a/tests/e2e/Dockerfile.response-limits.dockerignore b/tests/e2e/Dockerfile.response-limits.dockerignore new file mode 100644 index 000000000..39e1599a5 --- /dev/null +++ b/tests/e2e/Dockerfile.response-limits.dockerignore @@ -0,0 +1,5 @@ +** +!tests/ +!tests/e2e/ +!tests/e2e/response_limits_adapter.py +!tests/e2e/response-limits.fabric-adapter.json diff --git a/tests/e2e/code-review-response-limits-workload.sh b/tests/e2e/code-review-response-limits-workload.sh new file mode 100644 index 000000000..7d319abda --- /dev/null +++ b/tests/e2e/code-review-response-limits-workload.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Reuse the two-harness code-review image and controlled MCP source reader. +source "${FABRIC_ROOT}/tests/e2e/code-review-deadlines-workload.sh" +WORKLOAD_NAME=code-review-response-limits +EVIDENCE_FILE="${CODE_REVIEW_RESPONSE_EVIDENCE:?Set CODE_REVIEW_RESPONSE_EVIDENCE to a local report path}" +run_workload_consumer() { + CODE_REVIEW_GATEWAY="http://127.0.0.1:${GATEWAY_PORT}" \ + CODE_REVIEW_IMAGE="${RUNTIME_IMAGE}" \ + CODE_REVIEW_RESPONSE_EVIDENCE="${EVIDENCE_FILE}" \ + CODE_REVIEW_RESPONSE_LIMIT_LIVE=1 \ + "${DEMO_PYTHON_ENV}/bin/python" -m pytest tests/e2e/test_code_review_response_limits.py -v -s +} diff --git a/tests/e2e/response-limits-checklist.md b/tests/e2e/response-limits-checklist.md new file mode 100644 index 000000000..a7ff2a55e --- /dev/null +++ b/tests/e2e/response-limits-checklist.md @@ -0,0 +1,30 @@ + + +# Response-limit test checklist + +Use the current SDK, native core and rebuilt OpenShell provider/task images. +Keep logs, reports, endpoint addresses and run evidence outside tracked files. + +- [ ] Validate positive integer types, defaults, omitted fields and unknown keys. +- [ ] Check below, at and above each of the four independent budgets. +- [ ] Verify compact UTF-8 response counting, including multibyte characters. +- [ ] Verify explicit failed results with limit, configured/observed values and asset. +- [ ] Confirm a diagnostic can be returned even with a one-byte response budget. +- [ ] Verify duplicate artifact references count once and SDK diagnostics are excluded. +- [ ] Verify no partial artifact manifest is returned after a limit failure. +- [ ] Preserve artifact-reader traversal, symlink, FIFO and file-change protections. +- [ ] Run `just test-rust`, `just test-python` and `just test-typescript`. +- [ ] Build docs and native bindings; check schemas and generated references. +- [ ] Run `response-limits-workload.sh` through `examples/openshell/run-demo.sh`. + Check all 15 controlled cases on both host and OpenShell, including a 5 MiB + response, raised file count, binary files above 1 MiB and aggregate transfer + above the former provider transport cap. Verify complete file hashes. +- [ ] Run `code-review-response-limits-workload.sh` through the same launcher with + the local inference endpoint configured through the code-review environment + variables, including `CODE_REVIEW_RESPONSE_EVIDENCE` set to a persistent local + report path outside tracked files. Check Hermes and Deep Agents on host and OpenShell, with a tiny + rejecting budget and a raised accepting budget: eight cells, cleanup required. +- [ ] Review only the child branch diff against PR #601; preserve the GitHub stack. diff --git a/tests/e2e/response-limits-workload.sh b/tests/e2e/response-limits-workload.sh new file mode 100644 index 000000000..6ee717c63 --- /dev/null +++ b/tests/e2e/response-limits-workload.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +WORKLOAD_NAME=response-limits +SUPPORTED_MODES=(fabric_owned) +IMAGE_DOCKERFILE="${FABRIC_ROOT}/tests/e2e/Dockerfile.response-limits" +POLICY_FILE="${FABRIC_ROOT}/examples/openshell_echo/policy.yaml" +IMAGE_TAG=fabric-response-limits:test +stage_workload_image() { :; } +run_workload_consumer() { + RESPONSE_LIMIT_TEST_LIVE=1 \ + RESPONSE_LIMIT_TEST_GATEWAY="http://127.0.0.1:${GATEWAY_PORT}" \ + RESPONSE_LIMIT_TEST_IMAGE="${RUNTIME_IMAGE}" \ + "${DEMO_PYTHON_ENV}/bin/python" -m pytest tests/e2e/test_response_limits.py -v -s +} diff --git a/tests/e2e/response-limits.fabric-adapter.json b/tests/e2e/response-limits.fabric-adapter.json new file mode 100644 index 000000000..a3e012098 --- /dev/null +++ b/tests/e2e/response-limits.fabric-adapter.json @@ -0,0 +1,14 @@ +{ + "contract_version": "fabric.adapter/v1alpha2", + "adapter_id": "nvidia.fabric.test.response-limits", + "adapter_kind": "python", + "runner": { + "module": "response_limits_adapter", + "python": "/opt/fabric/bin/python", + "cwd": "/sandbox/workspace", + "env": {"PYTHONPATH": "/opt/fabric/agents/limits"} + }, + "requirements": {}, + "config": {"accepts": []}, + "capabilities": {"cancellation": false, "service": false, "streaming": false, "updates": false} +} diff --git a/tests/e2e/response_limits_adapter.py b/tests/e2e/response_limits_adapter.py new file mode 100644 index 000000000..850f7eea4 --- /dev/null +++ b/tests/e2e/response_limits_adapter.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Produce controlled complete outputs and binary artifacts through the real SDK.""" + +import hashlib +import os +from pathlib import Path + +from nemo_fabric_adapter_contract.models import ( + AgentArtifact, + AgentConfig, + AgentRunResult, + AgentRunStatus, +) +from nemo_fabric_adapters.common import lifecycle + + +class LimitRuntime: + async def start(self, payload): + pass + + async def invoke(self, request, context): + artifacts = [] + digests = [] + for index, size in enumerate(request.context.get("artifact_sizes", [])): + name = f"screenshot-{index}.png" + path = Path(context.artifacts.root) / name + content = os.urandom(size) + path.write_bytes(content) + digests.append(hashlib.sha256(content).hexdigest()) + artifacts.append( + AgentArtifact(name=name, kind="file", path=name, media_type="image/png") + ) + return AgentRunResult( + status=AgentRunStatus.SUCCEEDED, + output={ + "response": "x" * request.context.get("response_bytes", 16), + "sha256": digests, + }, + artifacts=artifacts, + ) + + async def stop(self): + pass + + +if __name__ == "__main__": + lifecycle.serve(LimitRuntime, config_loader=AgentConfig.from_mapping) diff --git a/tests/e2e/test_code_review_response_limits.py b/tests/e2e/test_code_review_response_limits.py new file mode 100644 index 000000000..612a8d870 --- /dev/null +++ b/tests/e2e/test_code_review_response_limits.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qualify normalized response budgets with two native harnesses and real inference. + +Uses the endpoint, policy and interpreter variables documented by + test_code_review_deadlines.py. Set CODE_REVIEW_RESPONSE_LIMIT_LIVE=1 and +CODE_REVIEW_RESPONSE_EVIDENCE to a local report path. Evidence is never checked in. +""" + +import asyncio +import json +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +from nemo_fabric import CheckRequest, Fabric, ResponseLimits +from tests.e2e.test_code_review_deadlines import BASE_DIR, build_config + +pytestmark = pytest.mark.skipif( + os.environ.get("CODE_REVIEW_RESPONSE_LIMIT_LIVE") != "1", + reason="live inference qualification is opt-in", +) + + +async def test_code_review_response_limit_matrix(tmp_path): + records = [] + start_lock = asyncio.Lock() + evidence = Path(os.environ["CODE_REVIEW_RESPONSE_EVIDENCE"]) + + async def run(harness, location, rejected): + directory = tmp_path / f"{harness}-{location}-{rejected}" + directory.mkdir() + token = f"review-{uuid4().hex}" + config = build_config(harness, location, "smoke", directory, token) + config.runtime.response_limits = ResponseLimits( + max_response_bytes=1 if rejected else 8 * 1024 * 1024 + ) + fabric = Fabric() + runtime = environment = None + record = { + "harness": harness, + "location": location, + "expected": "rejected" if rejected else "complete", + "status": "failed", + } + try: + if location == "openshell": + environment = await fabric.prepare_environment(config) + async with asyncio.timeout(60): + while ( + await fabric.check( + environment=environment, + request=CheckRequest(scope="server"), + ) + ).outcome != "passed": + await asyncio.sleep(0.2) + runtime = await fabric.start_runtime_in( + config, environment, base_dir=BASE_DIR + ) + else: + async with start_lock: + previous = os.environ.get("ADAPTER_PYTHON") + os.environ["ADAPTER_PYTHON"] = os.environ[ + f"CODE_REVIEW_{harness.upper()}_PYTHON" + ] + try: + runtime = await fabric.start_runtime(config, base_dir=BASE_DIR) + finally: + if previous is None: + os.environ.pop("ADAPTER_PYTHON", None) + else: + os.environ["ADAPTER_PYTHON"] = previous + result = await runtime.invoke( + input="Review calculator.py using read_review_source part 1 then part 2, as instructed." + ) + record["result"] = result.to_mapping() + if rejected: + assert result.status == "failed", result.to_mapping() + assert result.error.code == "response_limit_exceeded" + assert result.error.metadata["limit"] == "max_response_bytes" + assert result.error.metadata["configured"] == 1 + assert "RunResult" in result.error.message + assert result.output is None + else: + assert result.status == "succeeded", result.to_mapping() + assert result.output["completed"] is True + response = json.dumps(result.output["response"]) + assert token in response + assert "41" in response + record["status"] = "passed" + except Exception as error: + record["failure"] = str(error) + finally: + cleanup_errors = [] + if runtime is not None: + try: + await runtime.stop() + except Exception as error: + cleanup_errors.append({"operation": "stop", "message": str(error)}) + if environment is not None: + try: + await fabric.release_environment(environment) + except Exception as error: + cleanup_errors.append( + {"operation": "release", "message": str(error)} + ) + if cleanup_errors: + record["status"] = "failed" + record["cleanup_errors"] = cleanup_errors + records.append(record) + evidence.parent.mkdir(parents=True, exist_ok=True) + evidence.write_text( + json.dumps({"records": records}, indent=2, default=str) + "\n" + ) + print( + f"{harness}/{location}/{record['expected']}: {record['status']}", + flush=True, + ) + + async with asyncio.timeout(600): + await asyncio.gather( + *( + run(harness, location, rejected) + for harness in ("hermes", "deepagents") + for location in ("host", "openshell") + for rejected in (False, True) + ) + ) + assert len(records) == 8 + assert all(record["status"] == "passed" for record in records), records diff --git a/tests/e2e/test_response_limits.py b/tests/e2e/test_response_limits.py new file mode 100644 index 000000000..f092d2fbb --- /dev/null +++ b/tests/e2e/test_response_limits.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Exercise configured budgets through real host and OpenShell SDK processes. + +Run through response-limits-workload.sh with examples/openshell/run-demo.sh. +No inference is needed for byte-exact artifact production. Live inference uses +sibling test_code_review_response_limits.py with two native harnesses. +""" + +import asyncio +import hashlib +import json +import os +from pathlib import Path +import sys + +import pytest +from nemo_fabric import ( + CheckRequest, + DiscoveryConfig, + EnvironmentConfig, + Fabric, + FabricConfig, + HarnessConfig, + MetadataConfig, + ResponseLimits, + RunRequest, + RuntimeConfig, +) + +ROOT = Path(__file__).resolve().parents[2] +pytestmark = pytest.mark.skipif( + os.environ.get("RESPONSE_LIMIT_TEST_LIVE") != "1", + reason="live response-limit qualification is opt-in", +) + + +@pytest.mark.parametrize("location", ["host", "openshell"]) +async def test_response_limit_matrix(tmp_path, location): + descriptor_path = ROOT / "tests/e2e/response-limits.fabric-adapter.json" + descriptor = json.loads(descriptor_path.read_text()) + if location == "host": + descriptor["runner"] = { + "module": "response_limits_adapter", + "python": sys.executable, + "cwd": str(ROOT / "tests/e2e"), + } + descriptor_path = tmp_path / "limits.fabric-adapter.json" + descriptor_path.write_text(json.dumps(descriptor)) + config = FabricConfig( + metadata=MetadataConfig(name="response-limits"), + harness=HarnessConfig( + adapter_id=descriptor["adapter_id"], + resolution="image_provided" if location == "openshell" else "preinstalled", + ), + discovery=DiscoveryConfig(local_paths=[descriptor_path]), + runtime=RuntimeConfig(artifacts=tmp_path / "artifacts", timeout_seconds=60), + environment=EnvironmentConfig( + provider="local", workspace=tmp_path, artifacts=tmp_path / "artifacts" + ), + ) + fabric = Fabric() + environment = None + cases = [] + for field, boundary, sizes in [ + ("max_artifact_file_bytes", 64, [64]), + ("max_artifact_bytes", 64, [32, 32]), + ("max_artifact_files", 3, [1, 1, 1]), + ]: + for delta in (-1, 0, 1): + cases.append( + (field, {field: boundary + delta}, {"artifact_sizes": sizes}, delta < 0) + ) + cases.extend( + [ + ("max_response_bytes", {"max_response_bytes": 1}, {}, True), + ( + "max_response_bytes", + {"max_response_bytes": 8 * 1024 * 1024}, + {"response_bytes": 5 * 1024 * 1024}, + False, + ), + ( + "max_artifact_file_bytes", + {}, + {"artifact_sizes": [1024 * 1024 + 1]}, + True, + ), + ( + "max_artifact_file_bytes", + {"max_artifact_file_bytes": 2 * 1024 * 1024}, + {"artifact_sizes": [1024 * 1024 + 1]}, + False, + ), + ( + "max_artifact_bytes", + { + "max_artifact_bytes": 4 * 1024 * 1024, + "max_artifact_file_bytes": 2 * 1024 * 1024, + }, + {"artifact_sizes": [1024 * 1024 + 1] * 3}, + False, + ), + ( + "max_artifact_files", + {"max_artifact_files": 20}, + {"artifact_sizes": [1] * 17}, + False, + ), + ] + ) + if location == "openshell": + config.environment = EnvironmentConfig( + provider="openshell", + ownership="fabric_owned", + control_location="in_env_control", + workspace="/sandbox/workspace", + artifacts="/sandbox/artifacts", + connection={ + "gateway": os.environ["RESPONSE_LIMIT_TEST_GATEWAY"], + "workspace": "default", + }, + settings={ + "image": os.environ["RESPONSE_LIMIT_TEST_IMAGE"], + "command": ["fabric-agent-ctl", "serve"], + "policy_yaml": ( + ROOT / "examples/openshell_echo/policy.yaml" + ).read_text(), + "task_discovery_paths": ["/opt/fabric/agents/limits"], + }, + ) + environment = await fabric.prepare_environment(config) + try: + if environment is not None: + async with asyncio.timeout(60): + while ( + await fabric.check( + environment=environment, request=CheckRequest(scope="server") + ) + ).outcome != "passed": + await asyncio.sleep(0.2) + for field, limits, context, rejected in cases: + config.runtime.response_limits = ResponseLimits(**limits) + runtime = ( + await fabric.start_runtime_in(config, environment) + if environment is not None + else await fabric.start_runtime(config) + ) + try: + result = await runtime.invoke( + request=RunRequest(input="limits", context=context) + ) + if rejected: + assert result.status == "failed", ( + field, + limits, + result.to_mapping(), + ) + assert result.error.code == "response_limit_exceeded" + assert result.error.metadata["limit"] == field + assert result.error.metadata["asset"] in result.error.message + assert result.output is None + assert not result.artifacts.artifacts + else: + assert result.status == "succeeded", result.to_mapping() + assert result.output["response"] == "x" * context.get( + "response_bytes", 16 + ) + paths = [ + Path(artifact.path) + for artifact in result.artifacts.artifacts + if artifact.name.startswith("screenshot-") + ] + assert [path.stat().st_size for path in paths] == context.get( + "artifact_sizes", [] + ) + assert [ + hashlib.sha256(path.read_bytes()).hexdigest() for path in paths + ] == result.output["sha256"] + print( + f"{location}/{field}/{limits}: {'rejected' if rejected else 'complete'}", + flush=True, + ) + finally: + await runtime.stop() + finally: + if environment is not None: + await fabric.release_environment(environment) diff --git a/tests/fixtures/openshell_echo/sdk_provider.py b/tests/fixtures/openshell_echo/sdk_provider.py index 7a83a94be..580f96f5f 100644 --- a/tests/fixtures/openshell_echo/sdk_provider.py +++ b/tests/fixtures/openshell_echo/sdk_provider.py @@ -74,9 +74,13 @@ async def main(): else: await runtime.stop() value = None - if command["operation"] == "invoke" and os.environ.get("FABRIC_TEST_DROP_EXECUTION"): + if command["operation"] == "invoke" and os.environ.get( + "FABRIC_TEST_DROP_EXECUTION" + ): value.pop("execution", None) - if command["operation"] == "invoke" and os.environ.get("FABRIC_TEST_MALFORM_EXECUTION"): + if command["operation"] == "invoke" and os.environ.get( + "FABRIC_TEST_MALFORM_EXECUTION" + ): value["execution"] = "malformed optional evidence" fault = os.environ.get("FABRIC_TEST_PROVIDER_FAULT") if not fault_injected and ( @@ -94,6 +98,10 @@ async def main(): fault_injected = True value = {"stopped": True} output = {"status": "returned", "value": value} + elif operation == "collect_artifacts" and os.environ.get( + "FABRIC_TEST_ARTIFACT_LIMIT" + ): + output = json.loads(os.environ["FABRIC_TEST_ARTIFACT_LIMIT"]) elif operation == "collect_artifacts": reader = os.environ["FABRIC_TEST_ARTIFACT_READER"] result = subprocess.run( @@ -118,7 +126,9 @@ async def main(): for file in json.loads(result.stdout)["files"]: content = zlib.decompress(bytes(file["content"])) if len(content) != file["uncompressed_size"]: - raise ValueError("artifact reader returned an incorrect file size") + raise ValueError( + "artifact reader returned an incorrect file size" + ) output.append({"path": file["path"], "content": list(content)}) elif operation == "release": output = ( diff --git a/tests/python/runtime_server/test_transport.py b/tests/python/runtime_server/test_transport.py index 32254861b..00c0479ca 100644 --- a/tests/python/runtime_server/test_transport.py +++ b/tests/python/runtime_server/test_transport.py @@ -565,3 +565,63 @@ def record_timeout_at(deadline): finally: stop.set() await asyncio.wait_for(task, 2) + + +@pytest.mark.parametrize("runtime_id", [[], {}, None, 12]) +async def test_invalid_runtime_ids_receive_validation_envelope( + host, host_request, socket_path, runtime_id +): + stop, ready = asyncio.Event(), asyncio.Event() + task = asyncio.create_task( + transport.serve( + host, socket_path=socket_path, stop_event=stop, ready_event=ready + ) + ) + writer = None + try: + await asyncio.wait_for(ready.wait(), 1) + reader, writer = await asyncio.open_unix_connection(socket_path) + request = host_request("invoke", input="test", runtime_id=runtime_id) + writer.write(json.dumps(request).encode() + b"\n") + await writer.drain() + response = json.loads(await asyncio.wait_for(reader.readline(), 1)) + assert response["error"]["code"] == "invalid_request" + assert response["error"]["effects"] == "none" + finally: + if writer is not None: + writer.close() + await writer.wait_closed() + stop.set() + await asyncio.wait_for(task, 2) + + +async def test_default_client_accepts_envelope_over_raw_response_budget( + mock_runtime, host_request, socket_path +): + result = mock_runtime.invoke.return_value.to_mapping() + result["output"] = "" + raw_size = len( + json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode() + ) + result["output"] = "x" * (protocol.RESPONSE_LIMIT - raw_size - 20) + response = protocol.envelope("invoke", result, changed=None) + encoded = protocol.encode_response( + response, limit=protocol.response_transport_limit() + ) + assert len(encoded) > protocol.RESPONSE_LIMIT + + async def handle(reader, writer): + await reader.readline() + writer.write(encoded) + await writer.drain() + writer.close() + await writer.wait_closed() + + async with await asyncio.start_unix_server(handle, path=socket_path): + received = await asyncio.wait_for( + transport.client( + host_request("invoke", input="test"), socket_path=socket_path + ), + 5, + ) + assert received == response diff --git a/tests/python/test_openshell_artifact_reader.py b/tests/python/test_openshell_artifact_reader.py index 7cc37ba74..c654241a6 100644 --- a/tests/python/test_openshell_artifact_reader.py +++ b/tests/python/test_openshell_artifact_reader.py @@ -23,10 +23,10 @@ ) -def read(root, paths): +def read(root, paths, limits=None): process = subprocess.run( [sys.executable, str(READER)], - input=json.dumps({"root": str(root), "paths": paths}), + input=json.dumps({"root": str(root), "paths": paths, "limits": limits or {}}), text=True, capture_output=True, timeout=5, @@ -100,7 +100,15 @@ def test_export_rejects_nonregular_or_escaping_files(tmp_path, kind): (root / path).write_bytes(b"x" * (1024 * 1024 + 1)) code, result, stderr = read(root, [path]) assert code == 1 - assert result == {"error": "artifact_read_failed"} + if kind == "oversized": + assert result == dict( + limit="max_artifact_file_bytes", + configured=1024 * 1024, + observed=1024 * 1024 + 1, + asset=path, + ) + else: + assert result == {"error": "artifact_read_failed"} assert stderr == "" @@ -113,7 +121,11 @@ def test_export_rejects_limits_without_partial_file_results(tmp_path, case): (tmp_path / path).write_bytes(b"x" * size) code, result, stderr = read(tmp_path, paths) assert code == 1 - assert result == {"error": "artifact_read_failed"} + assert "files" not in result + assert result["limit"] == ( + "max_artifact_bytes" if case == "total_bytes" else "max_artifact_files" + ) + assert result["asset"] == paths[-1] assert stderr == "" @@ -138,7 +150,9 @@ def test_export_compresses_large_relay_files_within_transfer_limit(tmp_path): sizes = (687_836, 406_705) paths = [f"relay-{index}" for index in range(len(sizes))] for path, size in zip(paths, sizes, strict=True): - (tmp_path / path).write_bytes(b"event-data\n" * (size // 11) + b"x" * (size % 11)) + (tmp_path / path).write_bytes( + b"event-data\n" * (size // 11) + b"x" * (size % 11) + ) code, result, stderr = read(tmp_path, paths) @@ -153,11 +167,52 @@ def test_export_compresses_large_relay_files_within_transfer_limit(tmp_path): ) -def test_export_rejects_large_incompressible_file(tmp_path): +def test_export_preserves_large_incompressible_file(tmp_path): (tmp_path / "uncompressible").write_bytes(os.urandom(300_000)) code, result, stderr = read(tmp_path, ["uncompressible"]) - assert code == 1 - assert result == {"error": "artifact_read_failed"} + assert code == 0 + assert ( + zlib.decompress(bytes(result["files"][0]["content"])) + == (tmp_path / "uncompressible").read_bytes() + ) + assert stderr == "" + + +@pytest.mark.parametrize( + ("field", "boundary"), + [ + ("max_artifact_bytes", 10), + ("max_artifact_file_bytes", 6), + ("max_artifact_files", 2), + ], +) +@pytest.mark.parametrize("delta", [-1, 0, 1]) +def test_normalized_artifact_limits_below_at_above(tmp_path, field, boundary, delta): + paths = ["first.png", "second.png"] + (tmp_path / paths[0]).write_bytes(b"1" * 6) + (tmp_path / paths[1]).write_bytes(b"2" * 4) + code, result, stderr = read(tmp_path, paths, {field: boundary + delta}) assert stderr == "" + if delta < 0: + assert code == 1 + assert result["limit"] == field + assert result["configured"] == boundary + delta + assert result["asset"] in paths + assert "files" not in result + else: + assert code == 0 + assert [ + zlib.decompress(bytes(file["content"])) for file in result["files"] + ] == [b"1" * 6, b"2" * 4] + + +def test_raised_limits_export_complete_file_over_old_one_mib_cap(tmp_path): + content = os.urandom(1024 * 1024 + 1) + (tmp_path / "screenshot.png").write_bytes(content) + code, result, _ = read( + tmp_path, ["screenshot.png"], {"max_artifact_file_bytes": len(content)} + ) + assert code == 0 + assert zlib.decompress(bytes(result["files"][0]["content"])) == content diff --git a/tests/python/test_openshell_remote_runtime.py b/tests/python/test_openshell_remote_runtime.py index 83bc804f7..96e248ff2 100644 --- a/tests/python/test_openshell_remote_runtime.py +++ b/tests/python/test_openshell_remote_runtime.py @@ -590,3 +590,51 @@ async def test_invocation_timeout_is_preserved_without_a_provider_cap( def test_invalid_invocation_timeouts_are_rejected(timeout): with pytest.raises(ValueError): RuntimeConfig(timeout_seconds=timeout) + + +async def test_collection_limit_preserves_failed_result_and_active_runtime( + remote_config, tmp_path, monkeypatch +): + configured = 1024 * 1024 + monkeypatch.setenv( + "FABRIC_TEST_ARTIFACT_LIMIT", + json.dumps( + dict( + limit="max_artifact_file_bytes", + configured=configured, + observed=configured + 1, + asset="echo.json", + ) + ), + ) + fabric = Fabric() + environment = await fabric.attach_environment( + remote_config, + EnvironmentReference.from_mapping( + { + "provider": "openshell", + "resource": { + "sandbox_name": "echo", + "sandbox_id": remote_config.environment.connection["sandbox_id"], + }, + } + ), + ) + runtime = await fabric.start_runtime_in(remote_config, environment) + try: + await runtime.invoke(input="first") + result = await runtime.invoke(input="second") + assert result.status == "failed" + assert result.error.code == "response_limit_exceeded" + assert result.error.metadata == dict( + limit="max_artifact_file_bytes", + configured=configured, + observed=configured + 1, + asset="echo.json", + ) + assert result.output is None + assert not result.artifacts.artifacts + assert not list((tmp_path / "host-artifacts").rglob("echo.json")) + assert (await runtime.invoke(input="third")).status == "succeeded" + finally: + await runtime.stop() diff --git a/tests/python/test_response_limits.py b/tests/python/test_response_limits.py new file mode 100644 index 000000000..bd4e1094c --- /dev/null +++ b/tests/python/test_response_limits.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Normalized response-limit authoring and task transport budgets.""" + +import json + +import pytest +from pydantic import ValidationError + +from nemo_fabric import ResponseLimits, RuntimeConfig +from nemo_fabric.runtime_server.protocol import encode_response + +FIELDS = ( + "max_response_bytes", + "max_artifact_bytes", + "max_artifact_files", + "max_artifact_file_bytes", +) + + +@pytest.mark.parametrize("field", FIELDS) +@pytest.mark.parametrize("value", [0, -1, 1.5, True, "12", None, 1 << 32]) +def test_response_limits_reject_invalid_values(field, value): + with pytest.raises(ValidationError): + ResponseLimits(**{field: value}) + + +def test_response_limits_omitted_fields_retain_defaults(): + assert RuntimeConfig().response_limits is None + limits = RuntimeConfig( + response_limits={"max_response_bytes": 8_000_000} + ).response_limits + assert limits == ResponseLimits(max_response_bytes=8_000_000) + assert limits.max_artifact_bytes == 2 * 1024 * 1024 + assert limits.max_artifact_files == 16 + assert limits.max_artifact_file_bytes == 1024 * 1024 + with pytest.raises(ValidationError): + ResponseLimits(max_response_byte=100) + + +@pytest.mark.parametrize("delta", [-1, 0, 1]) +def test_bridge_encoding_uses_the_configured_transport_budget(delta): + response = {"operation": "invoke", "value": "é😀"} + encoded = ( + json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode() + b"\n" + ) + wire = encode_response(response, limit=len(encoded) + delta) + if delta >= 0: + assert wire == encoded + else: + assert json.loads(wire)["error"]["code"] == "response_too_large" + + +def test_bridge_can_carry_response_above_old_four_mib_cap(): + response = {"operation": "invoke", "value": "x" * (4 * 1024 * 1024)} + wire = encode_response(response, limit=8 * 1024 * 1024) + assert json.loads(wire) == response + + +@pytest.mark.parametrize("stop_failure", [False, True]) +async def test_python_convenience_run_rechecks_final_native_result(stop_failure): + from unittest.mock import MagicMock + from nemo_fabric.runtime import _run_native_lifecycle + + native = pytest.importorskip("nemo_fabric._native") + module = MagicMock() + result = dict( + agent_name="test", + harness="echo", + adapter_kind="python", + runtime_id="runtime-test", + invocation_id="invocation-test", + request_id="request-test", + status="succeeded", + output="done", + ) + module.start_runtime.return_value = "{}" + module.invoke_runtime.return_value = json.dumps(result) + if stop_failure: + module.stop_runtime.side_effect = RuntimeError("x" * 4096) + else: + module.stop_runtime.return_value = json.dumps( + [ + dict( + event_id="stop-test", + timestamp_millis=0, + kind="stop", + message="x" * 4096, + ) + ] + ) + module.finalize_run_result.side_effect = native.finalize_run_result + final = await _run_native_lifecycle( + module, + {"config": {"runtime": {"response_limits": {"max_response_bytes": 2048}}}}, + {}, + ) + assert final["status"] == "failed" + assert final["error"]["code"] == "response_limit_exceeded" + assert final["error"]["metadata"]["observed"] > 2048 + module.stop_runtime.assert_called_once() diff --git a/tests/python/test_runtime.py b/tests/python/test_runtime.py index 0934a31fa..e217e28dd 100644 --- a/tests/python/test_runtime.py +++ b/tests/python/test_runtime.py @@ -130,6 +130,7 @@ def invoke(plan_json: str, runtime_json: str, request_json: str) -> str: mock_native.invoke_runtime.side_effect = invoke mock_native.stop_runtime.return_value = json.dumps([]) + mock_native.finalize_run_result.side_effect = lambda result_json, limit: result_json return mock_native