From bd8094b19fb5d04177b25178931d751c145260d8 Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sat, 27 Jun 2026 10:44:07 -0600 Subject: [PATCH] Expose governed model dependency execution --- crates/traverse-cli/src/main.rs | 3 + .../src/application_manifest.rs | 8 +- .../src/workspace_app_state.rs | 40 +++- crates/traverse-runtime/src/inference.rs | 143 +++++++++++++- crates/traverse-runtime/src/lib.rs | 178 +++++++++++++++++- .../traverse-runtime/tests/inference_tests.rs | 155 ++++++++++++++- docs/model-dependency-authoring-guide.md | 9 +- docs/wasm-agent-authoring-guide.md | 4 +- .../ci/wasm_agent_authoring_guide_smoke.sh | 6 + 9 files changed, 526 insertions(+), 20 deletions(-) diff --git a/crates/traverse-cli/src/main.rs b/crates/traverse-cli/src/main.rs index 8f00dc4e..dfd51e4f 100644 --- a/crates/traverse-cli/src/main.rs +++ b/crates/traverse-cli/src/main.rs @@ -1620,12 +1620,14 @@ fn render_app_registration_state( let workflows = app_registration_workflows(manifest_path, manifest)?; let digest_verification = app_registration_digest_verification(manifest); let model_readiness = app_registration_model_readiness(manifest); + let model_dependencies = manifest.model_dependencies.clone(); let bundle_fingerprint = serde_json::json!({ "app_id": manifest.app_id.clone(), "app_version": manifest.version.clone(), "manifest_digest": manifest_digest.clone(), "components": components.clone(), "workflows": workflows.clone(), + "model_dependencies": model_dependencies.clone(), "model_readiness": model_readiness.clone(), "effective_config": { "values": manifest.effective_config.values.clone(), @@ -1648,6 +1650,7 @@ fn render_app_registration_state( "components": components, "workflows": workflows, "digest_verification": digest_verification, + "model_dependencies": model_dependencies, "model_readiness": model_readiness, "effective_config": { "values": manifest.effective_config.values.clone(), diff --git a/crates/traverse-registry/src/application_manifest.rs b/crates/traverse-registry/src/application_manifest.rs index ef371b1c..e60620f5 100644 --- a/crates/traverse-registry/src/application_manifest.rs +++ b/crates/traverse-registry/src/application_manifest.rs @@ -322,13 +322,13 @@ pub struct ApplicationWorkflowRef { pub path: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ApplicationEffectiveConfig { pub values: Value, pub redacted_secret_keys: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ApplicationModelDependency { pub interface_id: String, pub version_range: String, @@ -338,13 +338,13 @@ pub struct ApplicationModelDependency { pub candidates: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ModelSelectionPolicy { pub strategy: String, pub allow_fallback: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ModelCandidate { pub candidate_id: String, pub provider_capability_id: String, diff --git a/crates/traverse-registry/src/workspace_app_state.rs b/crates/traverse-registry/src/workspace_app_state.rs index 3400ecb1..8abb51d1 100644 --- a/crates/traverse-registry/src/workspace_app_state.rs +++ b/crates/traverse-registry/src/workspace_app_state.rs @@ -1,8 +1,8 @@ use crate::{ - ArtifactDigests, BinaryFormat, BinaryReference, CapabilityArtifactRecord, - CapabilityRegistration, CapabilityRegistry, ComposabilityMetadata, CompositionKind, - CompositionPattern, ImplementationKind, RegistryProvenance, RegistryScope, SourceKind, - SourceReference, WorkflowDefinition, WorkflowRegistration, WorkflowRegistry, + ApplicationModelDependency, ArtifactDigests, BinaryFormat, BinaryReference, + CapabilityArtifactRecord, CapabilityRegistration, CapabilityRegistry, ComposabilityMetadata, + CompositionKind, CompositionPattern, ImplementationKind, RegistryProvenance, RegistryScope, + SourceKind, SourceReference, WorkflowDefinition, WorkflowRegistration, WorkflowRegistry, }; use serde::Deserialize; use serde_json::Value; @@ -30,6 +30,7 @@ pub struct WorkspaceApplicationRegistration { pub manifest_path: String, pub manifest_digest: String, pub bundle_digest: String, + pub model_dependencies: Vec, pub state_path: PathBuf, } @@ -69,6 +70,8 @@ struct PersistedWorkspaceApplicationState { state_scope: String, components: Vec, workflows: Vec, + #[serde(default)] + model_dependencies: Vec, registration_fingerprint: Value, } @@ -168,6 +171,7 @@ pub fn load_workspace_application_registries( manifest_path: state.manifest_path, manifest_digest: state.manifest_digest, bundle_digest: state.bundle_digest, + model_dependencies: state.model_dependencies, state_path, }); } @@ -602,6 +606,10 @@ mod tests { assert_eq!(loaded.workspace_id, "local"); assert_eq!(loaded.applications.len(), 1); assert_eq!(loaded.applications[0].app_id, "expedition.readiness"); + assert_eq!( + loaded.applications[0].model_dependencies[0].interface_id, + "traverse.inference.generate" + ); assert!( loaded .capability_registry @@ -1159,6 +1167,30 @@ mod tests { "workflow_digest": "sha256:test-workflow", "path": repo.join("workflows/examples/expedition/plan-expedition/workflow.json").display().to_string() }], + "model_dependencies": [{ + "interface_id": "traverse.inference.generate", + "version_range": "^1.0", + "selection_policy": { + "strategy": "priority", + "allow_fallback": true + }, + "required_capabilities": ["text_generation"], + "minimum_context_window": 8192, + "candidates": [{ + "candidate_id": "ollama-llama-3-2-readiness", + "provider_capability_id": "traverse.inference.generate", + "provider_implementation_id": "ollama.local.generate", + "model_identifier": "llama3.2:3b", + "placement_target": "local", + "priority": 10, + "required_provider_config_keys": ["ollama_base_url"], + "metadata": { + "implementation_kind": "real_local_provider", + "provider": "ollama", + "model_context_window": 8192 + } + }] + }], "effective_config": { "values": { "workspace_id": "expedition-local", diff --git a/crates/traverse-runtime/src/inference.rs b/crates/traverse-runtime/src/inference.rs index 07b04b8b..c21b8d0b 100644 --- a/crates/traverse-runtime/src/inference.rs +++ b/crates/traverse-runtime/src/inference.rs @@ -7,10 +7,11 @@ use std::fmt; use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; +use traverse_contracts::ExecutionTarget; use traverse_registry::{ ApplicationModelDependency, ModelAvailabilityProbe, ModelCandidate, ModelCandidateAvailability, - ModelCandidateRejectionCode, ModelResolutionEvidence, ModelResolutionRequest, - resolve_model_dependency, + ModelCandidateRejectionCode, ModelResolutionEvidence, ModelResolutionPhase, + ModelResolutionRequest, resolve_model_dependency, }; const OLLAMA_PROVIDER: &str = "ollama"; @@ -59,6 +60,76 @@ pub struct OllamaInferenceEvidence { pub selected_model: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GovernedModelExecutionRequest { + pub interface_id: String, + pub prompt: String, + #[serde(default)] + pub system_prompt: Option, + #[serde(default)] + pub options: Value, + pub requested_placement: ExecutionTarget, + #[serde(default)] + pub provider_configs: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GovernedModelExecutionOutcome { + pub output: OllamaInferenceOutput, + pub model_resolution: ModelResolutionEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GovernedModelExecutionErrorCode { + InterfaceNotDeclared, + ModelDependencyUnsatisfied, + ProviderExecutionFailed, +} + +impl GovernedModelExecutionErrorCode { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::InterfaceNotDeclared => "model_interface_not_declared", + Self::ModelDependencyUnsatisfied => "model_dependency_unsatisfied", + Self::ProviderExecutionFailed => "model_provider_failure", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GovernedModelExecutionError { + pub code: GovernedModelExecutionErrorCode, + pub message: String, + pub model_resolution: Option>, +} + +impl GovernedModelExecutionError { + #[must_use] + pub fn new(code: GovernedModelExecutionErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + model_resolution: None, + } + } + + #[must_use] + pub fn with_model_resolution(mut self, evidence: ModelResolutionEvidence) -> Self { + self.model_resolution = Some(Box::new(evidence)); + self + } +} + +impl fmt::Display for GovernedModelExecutionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code.as_str(), self.message) + } +} + +impl std::error::Error for GovernedModelExecutionError {} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct OllamaInferenceProvider { config: OllamaProviderConfig, @@ -75,6 +146,10 @@ impl OllamaInferenceProvider { Ok(Self { config }) } + fn from_validated_config(config: OllamaProviderConfig) -> Self { + Self { config } + } + #[must_use] pub fn provider_implementation_id(&self) -> &'static str { "ollama.local.generate" @@ -226,6 +301,70 @@ pub fn resolve_ollama_model_dependency( resolve_model_dependency(dependency, request, probe) } +/// Resolves and executes one app-declared model dependency through Traverse. +/// +/// The caller supplies runtime-local provider configuration, while the selected +/// provider/model must come from the registered app dependency declaration. +/// +/// # Errors +/// +/// Returns [`GovernedModelExecutionError`] when the dependency does not match +/// the requested interface, no model candidate can be selected, selected +/// provider config is unavailable, or real provider execution fails. +pub fn execute_governed_ollama_model_dependency( + dependency: &ApplicationModelDependency, + request: &GovernedModelExecutionRequest, +) -> Result { + if dependency.interface_id != request.interface_id { + return Err(GovernedModelExecutionError::new( + GovernedModelExecutionErrorCode::InterfaceNotDeclared, + "requested inference interface is not declared by this app dependency", + )); + } + + let probe = request.provider_configs.iter().fold( + OllamaModelAvailabilityProbe::default(), + |probe, (implementation_id, config)| { + probe.with_provider_config(implementation_id.clone(), config.clone()) + }, + ); + let resolution_request = ModelResolutionRequest { + phase: ModelResolutionPhase::Execution, + requested_interface_id: request.interface_id.clone(), + requested_placement: request.requested_placement.clone(), + }; + let evidence = resolve_ollama_model_dependency(dependency, &resolution_request, &probe); + let Some(selected) = evidence.selected.as_ref() else { + return Err(GovernedModelExecutionError::new( + GovernedModelExecutionErrorCode::ModelDependencyUnsatisfied, + "no app-declared model candidate satisfied execution-time resolution", + ) + .with_model_resolution(evidence)); + }; + let provider = OllamaInferenceProvider::from_validated_config( + request.provider_configs[&selected.provider_implementation_id].clone(), + ); + let output = provider + .generate(&OllamaInferenceRequest { + model: selected.model_identifier.clone(), + prompt: request.prompt.clone(), + system_prompt: request.system_prompt.clone(), + options: request.options.clone(), + }) + .map_err(|error| { + GovernedModelExecutionError::new( + GovernedModelExecutionErrorCode::ProviderExecutionFailed, + error.to_string(), + ) + .with_model_resolution(evidence.clone()) + })?; + + Ok(GovernedModelExecutionOutcome { + output, + model_resolution: evidence, + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OllamaInferenceErrorCode { diff --git a/crates/traverse-runtime/src/lib.rs b/crates/traverse-runtime/src/lib.rs index d4198478..eefa7c91 100644 --- a/crates/traverse-runtime/src/lib.rs +++ b/crates/traverse-runtime/src/lib.rs @@ -28,8 +28,8 @@ use traverse_registry::{ CapabilityRegistration, CapabilityRegistry, DiscoveryQuery, ImplementationKind, LookupScope, ModelResolutionEvidence, RegistrationOutcome, RegistryFailure, RegistryScope, ResolutionError, ResolvedCapability, WorkflowFailure, WorkflowRegistration, WorkflowRegistrationOutcome, - WorkflowRegistry, WorkspaceAppStateFailure, load_workspace_application_registries, - resolve_dependencies, resolve_version_range, + WorkflowRegistry, WorkspaceAppStateFailure, WorkspaceApplicationRegistration, + load_workspace_application_registries, resolve_dependencies, resolve_version_range, }; const RUNTIME_REQUEST_KIND: &str = "runtime_request"; @@ -54,6 +54,7 @@ const TRACE_PREFIX: &str = "trace_"; pub struct Runtime { registry: CapabilityRegistry, workflow_registry: WorkflowRegistry, + applications: Vec, executor: E, observability: RuntimeObservabilityConfig, security: RuntimeSecurityConfig, @@ -65,6 +66,7 @@ impl Runtime { Self { registry, workflow_registry: WorkflowRegistry::new(), + applications: Vec::new(), executor, observability: RuntimeObservabilityConfig::default(), security: RuntimeSecurityConfig::default(), @@ -77,6 +79,15 @@ impl Runtime { self } + #[must_use] + pub fn with_workspace_applications( + mut self, + applications: Vec, + ) -> Self { + self.applications = applications; + self + } + /// Loads a runtime from durable local workspace app registration state. /// /// # Errors @@ -93,7 +104,8 @@ impl Runtime { let loaded = load_workspace_application_registries(workspace_root, workspace_id, validator_version)?; Ok(Self::new(loaded.capability_registry, executor) - .with_workflow_registry(loaded.workflow_registry)) + .with_workflow_registry(loaded.workflow_registry) + .with_workspace_applications(loaded.applications)) } #[must_use] @@ -146,6 +158,12 @@ impl Runtime { &self.workflow_registry } + /// Returns workspace application registrations loaded into this runtime. + #[must_use] + pub fn workspace_applications(&self) -> &[WorkspaceApplicationRegistration] { + self.applications.as_slice() + } + /// Returns a mutable reference to the workflow registry. #[must_use] pub fn workflow_registry_mut(&mut self) -> &mut WorkflowRegistry { @@ -165,6 +183,42 @@ impl Runtime { self.workflow_registry .register(&self.registry, registration) } + + /// Executes an app-declared model dependency through the governed inference surface. + /// + /// # Errors + /// + /// Returns [`inference::GovernedModelExecutionError`] when the app or + /// interface is not registered, model resolution fails, or provider + /// execution fails. + pub fn execute_governed_model_dependency( + &self, + app_id: &str, + app_version: &str, + request: &inference::GovernedModelExecutionRequest, + ) -> Result + { + let Some(application) = self.applications.iter().find(|application| { + application.app_id == app_id && application.app_version == app_version + }) else { + return Err(inference::GovernedModelExecutionError::new( + inference::GovernedModelExecutionErrorCode::InterfaceNotDeclared, + "application registration was not loaded into this runtime", + )); + }; + let Some(dependency) = application + .model_dependencies + .iter() + .find(|dependency| dependency.interface_id == request.interface_id) + else { + return Err(inference::GovernedModelExecutionError::new( + inference::GovernedModelExecutionErrorCode::InterfaceNotDeclared, + "requested inference interface is not declared by this application", + )); + }; + + inference::execute_governed_ollama_model_dependency(dependency, request) + } } pub trait LocalExecutor { @@ -2993,6 +3047,7 @@ mod tests { }; use ed25519_dalek::{Signer, SigningKey}; use serde_json::json; + use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -3068,6 +3123,99 @@ mod tests { ) .is_some() ); + assert_eq!( + runtime.workspace_applications()[0].model_dependencies[0].interface_id, + "traverse.inference.generate" + ); + } + + #[test] + fn governed_model_execution_resolves_from_loaded_app_declaration() { + let workspace_root = unique_workspace_state_dir(); + write_runtime_workspace_app_state_fixture(&workspace_root, "local"); + let runtime = Runtime::from_workspace_app_state( + &workspace_root, + "local", + NoopExecutor, + "test-runtime", + ) + .expect("workspace app state should load"); + let mut provider_configs = BTreeMap::new(); + provider_configs.insert( + "ollama.local.generate".to_string(), + crate::inference::OllamaProviderConfig { + base_url: "http://127.0.0.1:9".to_string(), + request_timeout_ms: Some(50), + }, + ); + + let error = runtime + .execute_governed_model_dependency( + "expedition.readiness", + "1.0.0", + &crate::inference::GovernedModelExecutionRequest { + interface_id: "traverse.inference.generate".to_string(), + prompt: "Summarize readiness.".to_string(), + system_prompt: None, + options: json!({}), + requested_placement: ExecutionTarget::Local, + provider_configs, + }, + ) + .expect_err("unavailable local provider should fail before output"); + + assert_eq!( + error.code, + crate::inference::GovernedModelExecutionErrorCode::ModelDependencyUnsatisfied + ); + let evidence = error + .model_resolution + .expect("failed model execution should include resolution evidence"); + assert_eq!( + evidence.requested_interface_id, + "traverse.inference.generate" + ); + assert_eq!( + evidence.machine_failure_code(), + Some("model_dependency_unsatisfied") + ); + } + + #[test] + fn governed_model_execution_rejects_missing_app_or_interface() { + let workspace_root = unique_workspace_state_dir(); + write_runtime_workspace_app_state_fixture(&workspace_root, "local"); + let runtime = Runtime::from_workspace_app_state( + &workspace_root, + "local", + NoopExecutor, + "test-runtime", + ) + .expect("workspace app state should load"); + let request = crate::inference::GovernedModelExecutionRequest { + interface_id: "traverse.inference.embed".to_string(), + prompt: "Summarize readiness.".to_string(), + system_prompt: None, + options: json!({}), + requested_placement: ExecutionTarget::Local, + provider_configs: BTreeMap::new(), + }; + + let missing_app = runtime + .execute_governed_model_dependency("missing.app", "1.0.0", &request) + .expect_err("unknown app should fail"); + assert_eq!( + missing_app.code, + crate::inference::GovernedModelExecutionErrorCode::InterfaceNotDeclared + ); + + let missing_interface = runtime + .execute_governed_model_dependency("expedition.readiness", "1.0.0", &request) + .expect_err("undeclared interface should fail"); + assert_eq!( + missing_interface.code, + crate::inference::GovernedModelExecutionErrorCode::InterfaceNotDeclared + ); } #[test] @@ -4506,6 +4654,30 @@ mod tests { "workflow_digest": "sha256:test-workflow", "path": repo.join("workflows/examples/expedition/plan-expedition/workflow.json").display().to_string() }], + "model_dependencies": [{ + "interface_id": "traverse.inference.generate", + "version_range": "^1.0", + "selection_policy": { + "strategy": "priority", + "allow_fallback": true + }, + "required_capabilities": ["text_generation"], + "minimum_context_window": 8192, + "candidates": [{ + "candidate_id": "ollama-llama-3-2-readiness", + "provider_capability_id": "traverse.inference.generate", + "provider_implementation_id": "ollama.local.generate", + "model_identifier": "llama3.2:3b", + "placement_target": "local", + "priority": 10, + "required_provider_config_keys": ["ollama_base_url"], + "metadata": { + "implementation_kind": "real_local_provider", + "provider": "ollama", + "model_context_window": 8192 + } + }] + }], "effective_config": { "values": { "workspace_id": "expedition-local", diff --git a/crates/traverse-runtime/tests/inference_tests.rs b/crates/traverse-runtime/tests/inference_tests.rs index 9a3d23eb..6daf4cbe 100644 --- a/crates/traverse-runtime/tests/inference_tests.rs +++ b/crates/traverse-runtime/tests/inference_tests.rs @@ -1,6 +1,7 @@ #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] use serde_json::json; +use std::collections::BTreeMap; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; @@ -17,8 +18,10 @@ use traverse_registry::{ ModelSelectionPolicy, RegistryProvenance, RegistryScope, SourceKind, SourceReference, }; use traverse_runtime::inference::{ + GovernedModelExecutionError, GovernedModelExecutionErrorCode, GovernedModelExecutionRequest, OllamaInferenceErrorCode, OllamaInferenceProvider, OllamaInferenceRequest, - OllamaModelAvailabilityProbe, OllamaProviderConfig, resolve_ollama_model_dependency, + OllamaModelAvailabilityProbe, OllamaProviderConfig, execute_governed_ollama_model_dependency, + resolve_ollama_model_dependency, }; #[test] @@ -293,6 +296,26 @@ fn ollama_error_codes_are_stable() { OllamaInferenceErrorCode::ProviderFailure.as_str(), "model_provider_failure" ); + assert_eq!( + GovernedModelExecutionErrorCode::InterfaceNotDeclared.as_str(), + "model_interface_not_declared" + ); + assert_eq!( + GovernedModelExecutionErrorCode::ModelDependencyUnsatisfied.as_str(), + "model_dependency_unsatisfied" + ); + assert_eq!( + GovernedModelExecutionErrorCode::ProviderExecutionFailed.as_str(), + "model_provider_failure" + ); + + let display = GovernedModelExecutionError::new( + GovernedModelExecutionErrorCode::InterfaceNotDeclared, + "missing interface", + ) + .to_string(); + assert!(display.contains("model_interface_not_declared")); + assert!(display.contains("missing interface")); } #[test] @@ -565,6 +588,120 @@ fn ollama_model_resolution_maps_provider_config_and_http_failures() { ); } +#[test] +fn governed_model_execution_invokes_selected_provider_and_returns_evidence() { + let base_url = start_ollama_server(vec![ + json!({"models": [{"name": "llama3.2:3b"}]}).to_string(), + json!({"models": [{"name": "llama3.2:3b"}]}).to_string(), + json!({"model": "llama3.2:3b", "response": "ready", "done": true}).to_string(), + ]); + let dependency = model_dependency(vec![model_candidate( + "ready-local", + "llama3.2:3b", + 20, + 8192, + )]); + + let outcome = execute_governed_ollama_model_dependency( + &dependency, + &governed_model_request("traverse.inference.generate", &base_url), + ) + .expect("available app-declared model should execute"); + + assert_eq!(outcome.output.response, "ready"); + assert_eq!( + outcome + .model_resolution + .selected + .expect("selected candidate should be recorded") + .candidate_id, + "ready-local" + ); +} + +#[test] +fn governed_model_execution_rejects_undeclared_interface() { + let dependency = model_dependency(vec![model_candidate( + "ready-local", + "llama3.2:3b", + 20, + 8192, + )]); + + let error = execute_governed_ollama_model_dependency( + &dependency, + &governed_model_request("traverse.inference.embed", "http://127.0.0.1:11434"), + ) + .expect_err("undeclared interface should fail before provider access"); + + assert_eq!( + error.code, + GovernedModelExecutionErrorCode::InterfaceNotDeclared + ); + assert!(error.model_resolution.is_none()); +} + +#[test] +fn governed_model_execution_reports_unsatisfied_dependency_evidence() { + let dependency = model_dependency(vec![model_candidate( + "missing-config", + "llama3.2:3b", + 20, + 8192, + )]); + let mut request = + governed_model_request("traverse.inference.generate", "http://127.0.0.1:11434"); + request.provider_configs.clear(); + + let error = execute_governed_ollama_model_dependency(&dependency, &request) + .expect_err("missing provider config should leave dependency unsatisfied"); + + assert_eq!( + error.code, + GovernedModelExecutionErrorCode::ModelDependencyUnsatisfied + ); + assert_eq!( + error + .model_resolution + .expect("resolution evidence should be attached") + .machine_failure_code(), + Some("model_dependency_unsatisfied") + ); +} + +#[test] +fn governed_model_execution_reports_provider_execution_failure_with_evidence() { + let base_url = start_ollama_server(vec![ + json!({"models": [{"name": "llama3.2:3b"}]}).to_string(), + json!({"models": [{"name": "llama3.2:3b"}]}).to_string(), + json!({"model": "llama3.2:3b", "done": true}).to_string(), + ]); + let dependency = model_dependency(vec![model_candidate( + "bad-generate", + "llama3.2:3b", + 20, + 8192, + )]); + + let error = execute_governed_ollama_model_dependency( + &dependency, + &governed_model_request("traverse.inference.generate", &base_url), + ) + .expect_err("invalid provider output should fail execution"); + + assert_eq!( + error.code, + GovernedModelExecutionErrorCode::ProviderExecutionFailed + ); + assert!( + error + .model_resolution + .expect("provider failure should retain selected model evidence") + .selected + .is_some() + ); +} + fn provider(base_url: &str) -> OllamaInferenceProvider { provider_with_timeout(base_url, 1_000) } @@ -626,6 +763,22 @@ fn model_candidate( } } +fn governed_model_request(interface_id: &str, base_url: &str) -> GovernedModelExecutionRequest { + let mut provider_configs = BTreeMap::new(); + provider_configs.insert( + "ollama.local.generate".to_string(), + provider_config(base_url, 1_000), + ); + GovernedModelExecutionRequest { + interface_id: interface_id.to_string(), + prompt: "Summarize readiness.".to_string(), + system_prompt: Some("Be concise.".to_string()), + options: json!({"temperature": 0}), + requested_placement: ExecutionTarget::Local, + provider_configs, + } +} + fn start_ollama_server(bodies: Vec) -> String { start_raw_server( bodies diff --git a/docs/model-dependency-authoring-guide.md b/docs/model-dependency-authoring-guide.md index 1218abdd..9824317d 100644 --- a/docs/model-dependency-authoring-guide.md +++ b/docs/model-dependency-authoring-guide.md @@ -13,7 +13,7 @@ traverse.inference.generate ``` Downstream apps declare this abstract interface plus concrete model candidates. -Traverse owns provider selection and later execution. Product code must not +Traverse owns provider selection and execution. Product code must not hardcode Ollama, llama.cpp, WebLLM, cloud APIs, or provider-specific paths. ## App Manifest Shape @@ -85,6 +85,7 @@ with `stream: false`. It reports stable machine-readable failures: - `model_provider_invalid_response` when Ollama returns malformed or incomplete JSON. -Full candidate resolution across multiple manifest entries is implemented in a -later Spec 045 slice. This provider slice gives that resolver a real local -implementation to call. +Runtime hosts execute app-declared model dependencies through Traverse's +governed model dependency surface. The runtime revalidates the selected +candidate at execution time, invokes the real provider implementation, and +returns public `ModelResolutionEvidence` alongside the inference output. diff --git a/docs/wasm-agent-authoring-guide.md b/docs/wasm-agent-authoring-guide.md index cc4df44d..fe29cd72 100644 --- a/docs/wasm-agent-authoring-guide.md +++ b/docs/wasm-agent-authoring-guide.md @@ -198,6 +198,6 @@ Agent manifests may declare `model_dependencies` — abstract interface names th **What they are**: Named contracts for LLM interface behaviour (e.g. "given this prompt format, return this JSON shape"). They are abstract — not tied to a specific model provider. -**How the runtime uses them**: In v0.1, `model_dependencies` are **documentation-only**. The runtime records them in the agent manifest but does not resolve or inject a model implementation automatically. The agent is responsible for calling the appropriate model API inside its WASM binary. +**How the runtime uses them**: App-level `model_dependencies` are governed runtime dependencies. Traverse loads the registered app declaration, resolves an available candidate for the requested abstract inference interface, invokes the governed provider implementation, and returns model resolution evidence with the inference output. A WASM agent must not hardcode Ollama, llama.cpp, WebLLM, cloud APIs, provider URLs, credentials, or provider SDK calls inside its binary. -**How to declare a new interface**: Choose a name that describes the capability + version (e.g. `"my-domain-classification-v1"`). Document the expected prompt format and output schema in a companion markdown file alongside the agent. There is no central interface registry in v0.1; naming is by convention. +**How to declare a new interface**: Use the governed interface `traverse.inference.generate` unless an approved spec adds another interface. Declare concrete candidates in the application manifest, keep provider configuration in runtime-local workspace config, and route execution through Traverse's governed model dependency surface. diff --git a/scripts/ci/wasm_agent_authoring_guide_smoke.sh b/scripts/ci/wasm_agent_authoring_guide_smoke.sh index b075ac89..937abea7 100755 --- a/scripts/ci/wasm_agent_authoring_guide_smoke.sh +++ b/scripts/ci/wasm_agent_authoring_guide_smoke.sh @@ -22,6 +22,12 @@ grep -q "workflow_refs" "${repo_root}/docs/wasm-agent-authoring-guide.md" grep -q "binary" "${repo_root}/docs/wasm-agent-authoring-guide.md" grep -q "constraints" "${repo_root}/docs/wasm-agent-authoring-guide.md" grep -q "model_dependencies" "${repo_root}/docs/wasm-agent-authoring-guide.md" +grep -q "governed runtime dependencies" "${repo_root}/docs/wasm-agent-authoring-guide.md" +grep -q "must not hardcode Ollama" "${repo_root}/docs/wasm-agent-authoring-guide.md" +if grep -q "documentation-only" "${repo_root}/docs/wasm-agent-authoring-guide.md"; then + echo "WASM agent guide must not describe model_dependencies as documentation-only." >&2 + exit 1 +fi grep -q "examples/templates/executable-capability-package/manifest.template.json" "${repo_root}/docs/wasm-agent-authoring-guide.md" grep -q "examples/agents/expedition-intent-agent/manifest.json" "${repo_root}/docs/wasm-agent-authoring-guide.md" grep -q "examples/agents/team-readiness-agent/manifest.json" "${repo_root}/docs/wasm-agent-authoring-guide.md"