use std::{path::PathBuf, sync::Arc, time::Instant};
use serde_json::Value;
use wasmtime::{
component::{Component, Linker},
Engine,
};
use crate::{
agent::{
build_driver_payload, count_tokens, parse_driver_usage, provider_counts, DriverUsage,
DRIVER_RETURNED_ERROR,
},
bindings::{
hook::murmur::runtime::inference::{InferenceRequest, InferenceResponse},
host::murmur::tool::run::{Status, ToolInput},
},
credential_gateway::{GatewayOffsets, UpstreamOutcome},
inference_retry::{
millis, AttemptFailure, InferenceRetryRecord, RetryDecision, RetryNote, RetryPolicy,
RetryReason,
},
limits::{CallDeadlines, ComponentRole},
network_policy::{NetworkAllowRule, ToolCapabilityGrant},
runtime::{invoke_tool_component, ToolA2aWiring, ToolInvokeEnv},
session_status::SessionDeadline,
spend::{SpendMeter, SpendRefusal},
trace::{
recorded_driver_config, InferenceCallSettings, InferenceOrigin,
INFERENCE_ERROR_CREDENTIAL_REJECTED, INFERENCE_ERROR_DRIVER_ERROR,
INFERENCE_ERROR_DRIVER_FAILED, INFERENCE_ERROR_MALFORMED_RESPONSE,
},
types::CapabilityPolicy,
};
pub(crate) const INFERENCE_IFACE_VERSIONED: &str = "murmur:runtime/inference@0.4.0";
#[derive(Debug, Clone)]
pub(crate) struct HookInferenceRecord {
pub(crate) origin: InferenceOrigin,
pub(crate) input_tokens: u64,
pub(crate) output_tokens: Option<u64>,
pub(crate) decision: String,
pub(crate) duration_ms: u64,
pub(crate) usage: Option<DriverUsage>,
pub(crate) failure: Option<HookInferenceFailure>,
pub(crate) settings: InferenceCallSettings,
pub(crate) retry: RetryNote,
pub(crate) gateway: Option<GatewayOffsets>,
}
#[derive(Debug, Clone)]
pub(crate) struct HookInferenceFailure {
pub(crate) code: &'static str,
pub(crate) message: String,
pub(crate) error: String,
pub(crate) provider_status: Option<u16>,
pub(crate) usage: Option<DriverUsage>,
}
struct FailedAttempt {
failure: HookInferenceFailure,
shape: Option<AttemptFailure>,
transient: Option<bool>,
outcome: Option<UpstreamOutcome>,
}
struct DriverFailure {
code: &'static str,
message: String,
usage: Option<DriverUsage>,
shape: Option<AttemptFailure>,
transient: Option<bool>,
}
#[derive(Debug, Clone)]
pub(crate) struct SpendRefusalRecord {
pub(crate) origin: String,
pub(crate) refusal: SpendRefusal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum HookInferencePurpose {
#[default]
Lifecycle,
Compaction,
}
pub(crate) struct HookInferenceCtx {
pub(crate) driver_name: String,
pub(crate) driver_component: Component,
pub(crate) model: String,
pub(crate) output_caps: crate::model_limits::HookOutputCaps,
pub(crate) engine: Engine,
pub(crate) accessible_workdir: PathBuf,
pub(crate) records_root: crate::workdir_fs::WorkdirRoot,
pub(crate) inference_env: Vec<(String, String)>,
pub(crate) compaction_inference_env: Vec<(String, String)>,
pub(crate) capability_policy: CapabilityPolicy,
pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
pub(crate) driver_grant: Option<ToolCapabilityGrant>,
pub(crate) gateway: Option<Arc<crate::credential_gateway::CredentialGateway>>,
pub(crate) gateways: crate::credential_gateway::GatewayTable,
pub(crate) formation: Option<Arc<crate::formation_credentials::FormationMember>>,
pub(crate) spend: Arc<SpendMeter>,
pub(crate) records: std::sync::Mutex<Vec<HookInferenceRecord>>,
pub(crate) spend_refusals: std::sync::Mutex<Vec<SpendRefusalRecord>>,
pub(crate) retries: std::sync::Mutex<Vec<InferenceRetryRecord>>,
pub(crate) retry: RetryPolicy,
pub(crate) deadline: Option<SessionDeadline>,
pub(crate) call_deadlines: CallDeadlines,
}
impl HookInferenceCtx {
async fn run(
&self,
origin: &str,
purpose: HookInferencePurpose,
request: InferenceRequest,
) -> Result<InferenceResponse, String> {
let messages = wire_messages(&request);
let model = request.model.unwrap_or_else(|| self.model.clone());
let max_output_tokens = self.output_caps.for_call(&model, purpose);
let payload = build_driver_payload(
&model,
max_output_tokens,
&messages,
&[],
request.system_prompt.as_deref().unwrap_or(""),
None,
None,
);
let payload_json = serde_json::to_string(&payload)
.map_err(|e| format!("failed to encode driver payload: {e}"))?;
let input_tokens = u64::from(count_tokens(&payload_json));
let inference_env = self.env_for(purpose);
let settings = InferenceCallSettings {
max_output_tokens,
input_budget: self.output_caps.input_budget(&model, max_output_tokens),
driver_config: recorded_driver_config(inference_env, |text| self.redact(text)),
};
let mut retries_made = 0u32;
loop {
let admission = match self.spend.admit(input_tokens, u64::from(max_output_tokens)) {
Ok(admission) => admission,
Err(refusal) => {
let text = refusal.to_string();
if let Ok(mut refusals) = self.spend_refusals.lock() {
refusals.push(SpendRefusalRecord {
origin: origin.to_string(),
refusal,
});
}
return Err(text);
}
};
let started = Instant::now();
let (outcome, gateway_timing) = self
.dispatch(payload_json.clone(), inference_env, started)
.await;
let duration_ms = started.elapsed().as_millis() as u64;
let (output_tokens, usage, failure, result, stopped) = match outcome {
Ok((raw, text, usage)) => {
let output_tokens = u64::from(count_tokens(&raw));
match usage.as_ref().and_then(provider_counts) {
Some(counts) => admission.settle(counts.request, counts.completion),
None => admission.settle(input_tokens, output_tokens),
}
(
Some(output_tokens),
usage,
None,
Ok(InferenceResponse {
text,
model_used: model.clone(),
input_tokens,
output_tokens,
}),
None,
)
}
Err(failed) => {
let FailedAttempt {
failure,
shape,
transient,
outcome,
} = *failed;
let retryable = shape.is_some_and(|shape| {
crate::inference_retry::classify(
shape,
transient,
failure.code == INFERENCE_ERROR_CREDENTIAL_REJECTED,
outcome,
)
});
let decision = retryable.then(|| {
self.retry.decide(
retries_made,
outcome.and_then(UpstreamOutcome::retry_after),
crate::inference_retry::random_draw(),
self.deadline.map(|deadline| deadline.instant()),
Instant::now(),
)
});
let stopped = match decision {
Some(RetryDecision::Retry { wait }) => {
let reason = RetryReason::of(outcome);
let charged_tokens = crate::inference_retry::charge_retried_attempt(
admission,
reason,
failure.usage.as_ref().and_then(provider_counts),
);
retries_made += 1;
self.record_retry(InferenceRetryRecord {
origin: Some(origin.to_string()),
attempt: retries_made,
max_retries: self.retry.max_retries,
reason,
provider_status: reason.provider_status(outcome),
error: failure.error,
delay_ms: millis(wait),
retry_after_ms: outcome
.and_then(UpstreamOutcome::retry_after)
.map(millis),
charged_tokens,
});
tokio::time::sleep(wait).await;
continue;
}
Some(RetryDecision::Stop(stop)) => Some(stop),
None => None,
};
Self::charge_failed(admission, failure.usage.as_ref());
let message = failure.message.clone();
(
None,
failure.usage.clone(),
Some(failure),
Err(message),
stopped,
)
}
};
self.record(HookInferenceRecord {
origin: InferenceOrigin {
source: origin.to_string(),
model,
},
input_tokens,
output_tokens,
decision: if result.is_ok() { "end_turn" } else { "error" }.to_string(),
duration_ms,
usage,
failure,
settings,
retry: RetryNote::new(retries_made + 1, stopped),
gateway: gateway_timing,
});
return result;
}
}
fn charge_failed(admission: crate::spend::SpendAdmission, usage: Option<&DriverUsage>) {
match usage.and_then(provider_counts) {
Some(counts) => admission.settle(counts.request, counts.completion),
None => drop(admission),
}
}
fn env_for(&self, purpose: HookInferencePurpose) -> &[(String, String)] {
match purpose {
HookInferencePurpose::Lifecycle => &self.inference_env,
HookInferencePurpose::Compaction => &self.compaction_inference_env,
}
}
async fn dispatch(
&self,
payload_json: String,
inference_env: &[(String, String)],
started: Instant,
) -> (
Result<(String, String, Option<DriverUsage>), Box<FailedAttempt>>,
Option<GatewayOffsets>,
) {
if let Some(gateway) = &self.gateway {
gateway.take_upstream_outcome();
gateway.take_timing();
}
let result = self.dispatch_driver(payload_json, inference_env).await;
let outcome = self
.gateway
.as_ref()
.and_then(|gateway| gateway.take_upstream_outcome());
let timing = self
.gateway
.as_ref()
.map(|gateway| gateway.take_timing().offsets_from(started));
let result = result.map_err(|failed| {
let DriverFailure {
code,
message,
usage,
shape,
transient,
} = *failed;
Box::new(FailedAttempt {
failure: HookInferenceFailure {
code,
error: match code {
INFERENCE_ERROR_CREDENTIAL_REJECTED => message.clone(),
_ => self.redact(&message),
},
message,
provider_status: outcome.and_then(UpstreamOutcome::status),
usage,
},
shape,
transient,
outcome,
})
});
(result, timing)
}
async fn dispatch_driver(
&self,
payload_json: String,
inference_env: &[(String, String)],
) -> Result<(String, String, Option<DriverUsage>), Box<DriverFailure>> {
let result = invoke_tool_component(
ToolInvokeEnv {
engine: &self.engine,
accessible_workdir: &self.accessible_workdir,
runtime_dir: None,
extra_roots: &[],
inference_env,
capability_policy: &self.capability_policy,
network_allow_rules: &self.network_allow_rules,
artifact_grant: self.driver_grant.as_ref(),
gateway: self.gateway.as_ref(),
formation: self.formation.as_ref(),
task_provenance: None,
role: ComponentRole::Driver,
call_deadlines: &self.call_deadlines,
process: crate::session_process::ProcessAccess::Unavailable(
crate::session_process::unavailable::DRIVER,
),
},
ToolA2aWiring::silent(),
&self.driver_name,
&self.driver_component,
ToolInput {
data: Some(payload_json),
log_path: None,
},
)
.await
.map_err(|e| {
Box::new(DriverFailure {
code: INFERENCE_ERROR_DRIVER_FAILED,
message: format!("inference driver '{}' failed: {e}", self.driver_name),
usage: None,
shape: Some(AttemptFailure::Dispatch),
transient: None,
})
})?;
let passed = matches!(result.status, Status::Passed);
let transient = crate::inference_retry::transient_metadata(&result.metadata);
let raw = result.data.or(result.summary).ok_or_else(|| {
Box::new(DriverFailure {
code: if passed {
INFERENCE_ERROR_MALFORMED_RESPONSE
} else {
INFERENCE_ERROR_DRIVER_ERROR
},
message: "inference driver returned no data".to_string(),
usage: None,
shape: (!passed).then_some(AttemptFailure::Status),
transient,
})
})?;
if !passed {
if let Some(message) = self.credential_failure() {
return Err(Box::new(DriverFailure {
code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
message,
usage: None,
shape: Some(AttemptFailure::Status),
transient,
}));
}
let usage = serde_json::from_str::<Value>(&raw)
.ok()
.and_then(|response| parse_driver_usage(&response));
return Err(Box::new(DriverFailure {
code: INFERENCE_ERROR_DRIVER_ERROR,
message: format!("inference driver returned an error: {raw}"),
usage,
shape: Some(AttemptFailure::Status),
transient,
}));
}
let response: Value = serde_json::from_str(&raw).map_err(|e| {
Box::new(DriverFailure {
code: INFERENCE_ERROR_MALFORMED_RESPONSE,
message: format!("failed to parse inference driver response: {e}"),
usage: None,
shape: None,
transient: None,
})
})?;
if response.get("stop_reason").and_then(Value::as_str) == Some("error") {
let usage = parse_driver_usage(&response);
if let Some(message) = self.credential_failure() {
return Err(Box::new(DriverFailure {
code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
message,
usage,
shape: Some(AttemptFailure::StopReasonError),
transient,
}));
}
let err = response
.get("error")
.and_then(Value::as_str)
.unwrap_or(DRIVER_RETURNED_ERROR);
return Err(Box::new(DriverFailure {
code: INFERENCE_ERROR_DRIVER_ERROR,
message: format!("inference driver returned an error: {err}"),
usage,
shape: Some(AttemptFailure::StopReasonError),
transient,
}));
}
let text = response_text(&response);
let usage = parse_driver_usage(&response);
Ok((raw, text, usage))
}
fn credential_failure(&self) -> Option<String> {
let message = self
.gateway
.as_ref()
.and_then(|gateway| gateway.credential())
.and_then(|credential| credential.report_rejection())?;
if let Err(err) =
crate::agent::write_result(&self.records_root, &format!("error: {message}"))
{
crate::runtime_err!("{err}");
}
Some(message)
}
fn redact(&self, text: &str) -> String {
self.gateways.redact(text)
}
fn record(&self, record: HookInferenceRecord) {
if let Ok(mut guard) = self.records.lock() {
guard.push(record);
}
}
pub(crate) fn drain_records(&self) -> Vec<HookInferenceRecord> {
self.records
.lock()
.map(|mut g| std::mem::take(&mut *g))
.unwrap_or_default()
}
fn record_retry(&self, retry: InferenceRetryRecord) {
if let Ok(mut guard) = self.retries.lock() {
guard.push(retry);
}
}
pub(crate) fn drain_retries(&self) -> Vec<InferenceRetryRecord> {
self.retries
.lock()
.map(|mut g| std::mem::take(&mut *g))
.unwrap_or_default()
}
pub(crate) fn drain_spend_refusals(&self) -> Vec<SpendRefusalRecord> {
self.spend_refusals
.lock()
.map(|mut g| std::mem::take(&mut *g))
.unwrap_or_default()
}
}
fn wire_messages(request: &InferenceRequest) -> Vec<Value> {
request
.messages
.iter()
.map(|m| {
serde_json::json!({
"role": m.role,
"content": [{"type": "text", "text": m.content}],
})
})
.collect()
}
fn response_text(response: &Value) -> String {
response
.get("content")
.and_then(Value::as_array)
.map(|blocks| {
blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InferenceUnavailable {
NotConfigured,
ProcessTransport,
}
impl InferenceUnavailable {
pub(crate) fn message(self) -> &'static str {
match self {
Self::NotConfigured => {
"inference driver is not configured; add inference.driver.artifact to murmur.yaml"
}
Self::ProcessTransport => {
"run-inference is not available under inference.transport: process: the harness \
runs the model, and this capsule has no inference driver the runtime can call"
}
}
}
}
pub(crate) fn add_inference_to_linker<T: 'static>(
linker: &mut Linker<T>,
origin: String,
purpose: fn(&T) -> HookInferencePurpose,
ctx: Result<Arc<HookInferenceCtx>, InferenceUnavailable>,
) -> Result<(), String> {
let origin = Arc::new(origin);
linker
.instance(INFERENCE_IFACE_VERSIONED)
.map_err(|e| format!("failed to define {INFERENCE_IFACE_VERSIONED}: {e}"))?
.func_wrap_async(
"run-inference",
move |store: wasmtime::StoreContextMut<'_, T>, (request,): (InferenceRequest,)| {
let purpose = purpose(store.data());
let ctx = ctx.clone();
let origin = Arc::clone(&origin);
let fut: Box<
dyn std::future::Future<
Output = wasmtime::Result<(Result<InferenceResponse, String>,)>,
> + Send,
> = Box::new(async move {
let result = match ctx {
Ok(ctx) => ctx.run(&origin, purpose, request).await,
Err(reason) => Err(reason.message().to_string()),
};
Ok((result,))
});
fut
},
)
.map_err(|e| {
format!("failed to register {INFERENCE_IFACE_VERSIONED}#run-inference: {e}")
})?;
Ok(())
}
#[cfg(test)]
pub(crate) mod test_support {
use wasmtime::component::Component;
pub(crate) fn driver_double(
engine: &wasmtime::Engine,
status: u32,
response: &str,
) -> Component {
driver_double_with_metadata(engine, status, response, &[])
}
pub(crate) fn trapping_driver_double(engine: &wasmtime::Engine) -> Component {
bare_driver_double(engine, "unreachable")
}
pub(crate) fn no_data_driver_double(engine: &wasmtime::Engine) -> Component {
bare_driver_double(engine, "(i32.const 128)")
}
fn bare_driver_double(engine: &wasmtime::Engine, run_body: &str) -> Component {
let wat = format!(
r#"(component
(core module $m
(memory (export "memory") 1)
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(i32.const 1024))
(func (export "run") (param i32 i32 i32 i32 i32 i32) (result i32)
{run_body})
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(type $status (enum "passed" "failed" "error"))
(type $tool-input (record
(field "data" (option string))
(field "log-path" (option string))))
(type $attachment (record
(field "media-type" string)
(field "path" string)))
(type $tool-result (record
(field "status" $status)
(field "summary" (option string))
(field "data" (option string))
(field "data-path" (option string))
(field "truncated" bool)
(field "metadata" (list (tuple string string)))
(field "attachments" (list $attachment))))
(type $ft (func (param "input" $tool-input) (result $tool-result)))
(func $run (type $ft)
(canon lift (core func $i "run") (memory $mem) (realloc $realloc) string-encoding=utf8))
(instance $ti
(export "status" (type $status))
(export "tool-input" (type $tool-input))
(export "attachment" (type $attachment))
(export "tool-result" (type $tool-result))
(export "run" (func $run)))
(export "murmur:tool/run@0.2.0" (instance $ti))
)"#
);
let bytes = wat::parse_str(&wat).expect("bare driver double WAT parses");
Component::new(engine, &bytes).expect("bare driver double compiles")
}
pub(crate) fn driver_double_switching(
engine: &wasmtime::Engine,
marker: &str,
before: &str,
after: &str,
) -> Component {
let escape =
|text: &str| -> String { text.bytes().map(|b| format!("\\{b:02x}")).collect() };
let marker_len = marker.len();
assert!(
2048 + marker_len <= 4096,
"the marker must fit below the responses"
);
let (before_at, before_len) = (4096, before.len());
let after_at = before_at + before_len + 8;
let after_len = after.len();
let bump_start = after_at + after_len + 8;
let (marker_data, before_data, after_data) =
(escape(marker), escape(before), escape(after));
let wat = format!(
r#"(component
(core module $m
(memory (export "memory") 4)
(global $bump (mut i32) (i32.const {bump_start}))
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(local $p i32)
(local.set $p (i32.and (i32.add (global.get $bump) (i32.const 7)) (i32.const -8)))
(global.set $bump (i32.add (local.get $p) (i32.add (local.get 3) (i32.const 8))))
(local.get $p))
(func $contains (param $p i32) (param $n i32) (result i32)
(local $i i32) (local $j i32)
(loop $outer
(if (i32.gt_u (i32.add (local.get $i) (i32.const {marker_len})) (local.get $n))
(then (return (i32.const 0))))
(local.set $j (i32.const 0))
(block $mismatch
(loop $inner
(if (i32.eq (local.get $j) (i32.const {marker_len}))
(then (return (i32.const 1))))
(br_if $mismatch
(i32.ne
(i32.load8_u (i32.add (local.get $p) (i32.add (local.get $i) (local.get $j))))
(i32.load8_u (i32.add (i32.const 2048) (local.get $j)))))
(local.set $j (i32.add (local.get $j) (i32.const 1)))
(br $inner)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $outer))
(i32.const 0))
(func (export "run") (param i32 i32 i32 i32 i32 i32) (result i32)
(i32.store8 (i32.const 128) (i32.const 0))
(i32.store (i32.const 132) (i32.const 0))
(i32.store (i32.const 144) (i32.const 1))
(if (i32.and (local.get 0) (call $contains (local.get 1) (local.get 2)))
(then
(i32.store (i32.const 148) (i32.const {after_at}))
(i32.store (i32.const 152) (i32.const {after_len})))
(else
(i32.store (i32.const 148) (i32.const {before_at}))
(i32.store (i32.const 152) (i32.const {before_len}))))
(i32.store (i32.const 156) (i32.const 0))
(i32.store8 (i32.const 168) (i32.const 0))
(i32.store (i32.const 172) (i32.const 0))
(i32.store (i32.const 176) (i32.const 0))
(i32.store (i32.const 180) (i32.const 0))
(i32.store (i32.const 184) (i32.const 0))
(i32.const 128))
(data (i32.const 2048) "{marker_data}")
(data (i32.const {before_at}) "{before_data}")
(data (i32.const {after_at}) "{after_data}")
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(type $status (enum "passed" "failed" "error"))
(type $tool-input (record
(field "data" (option string))
(field "log-path" (option string))))
(type $attachment (record
(field "media-type" string)
(field "path" string)))
(type $tool-result (record
(field "status" $status)
(field "summary" (option string))
(field "data" (option string))
(field "data-path" (option string))
(field "truncated" bool)
(field "metadata" (list (tuple string string)))
(field "attachments" (list $attachment))))
(type $ft (func (param "input" $tool-input) (result $tool-result)))
(func $run (type $ft)
(canon lift (core func $i "run") (memory $mem) (realloc $realloc) string-encoding=utf8))
(instance $ti
(export "status" (type $status))
(export "tool-input" (type $tool-input))
(export "attachment" (type $attachment))
(export "tool-result" (type $tool-result))
(export "run" (func $run)))
(export "murmur:tool/run@0.2.0" (instance $ti))
)"#
);
let bytes = wat::parse_str(&wat).expect("switching driver double WAT parses");
Component::new(engine, &bytes).expect("switching driver double compiles")
}
pub(crate) fn driver_double_with_metadata(
engine: &wasmtime::Engine,
status: u32,
response: &str,
metadata: &[(&str, &str)],
) -> Component {
let len = response.len();
let escape =
|text: &str| -> String { text.bytes().map(|b| format!("\\{b:02x}")).collect() };
let escaped = escape(response);
let mut metadata_stores = String::new();
let mut metadata_data = String::new();
let mut next_string = 256usize;
for (index, (key, value)) in metadata.iter().enumerate() {
let tuple = 192 + 16 * index;
for (offset, text) in [(0, key), (8, value)] {
metadata_stores.push_str(&format!(
" (i32.store (i32.const {}) (i32.const {next_string}))\n \
(i32.store (i32.const {}) (i32.const {}))\n",
tuple + offset,
tuple + offset + 4,
text.len()
));
metadata_data.push_str(&format!(
" (data (i32.const {next_string}) \"{}\")\n",
escape(text)
));
next_string += text.len();
}
}
assert!(
next_string <= 4096,
"metadata strings must fit below the response"
);
let bump_start = 4096 + len + 8;
let metadata_ptr = if metadata.is_empty() { 0 } else { 192 };
let metadata_len = metadata.len();
let wat = format!(
r#"(component
(core module $m
(memory (export "memory") 4)
(global $bump (mut i32) (i32.const {bump_start}))
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(local $p i32)
(local.set $p (i32.and (i32.add (global.get $bump) (i32.const 7)) (i32.const -8)))
(global.set $bump (i32.add (local.get $p) (i32.add (local.get 3) (i32.const 8))))
(local.get $p))
(func (export "run") (param i32 i32 i32 i32 i32 i32) (result i32)
(i32.store8 (i32.const 128) (i32.const {status}))
(i32.store (i32.const 132) (i32.const 0))
(i32.store (i32.const 144) (i32.const 1))
(i32.store (i32.const 148) (i32.const 4096))
(i32.store (i32.const 152) (i32.const {len}))
(i32.store (i32.const 156) (i32.const 0))
(i32.store8 (i32.const 168) (i32.const 0))
(i32.store (i32.const 172) (i32.const {metadata_ptr}))
(i32.store (i32.const 176) (i32.const {metadata_len}))
(i32.store (i32.const 180) (i32.const 0))
(i32.store (i32.const 184) (i32.const 0))
{metadata_stores} (i32.const 128))
(data (i32.const 4096) "{escaped}")
{metadata_data}
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(type $status (enum "passed" "failed" "error"))
(type $tool-input (record
(field "data" (option string))
(field "log-path" (option string))))
(type $attachment (record
(field "media-type" string)
(field "path" string)))
(type $tool-result (record
(field "status" $status)
(field "summary" (option string))
(field "data" (option string))
(field "data-path" (option string))
(field "truncated" bool)
(field "metadata" (list (tuple string string)))
(field "attachments" (list $attachment))))
(type $ft (func (param "input" $tool-input) (result $tool-result)))
(func $run (type $ft)
(canon lift (core func $i "run") (memory $mem) (realloc $realloc) string-encoding=utf8))
(instance $ti
(export "status" (type $status))
(export "tool-input" (type $tool-input))
(export "attachment" (type $attachment))
(export "tool-result" (type $tool-result))
(export "run" (func $run)))
(export "murmur:tool/run@0.2.0" (instance $ti))
)"#
);
let bytes = wat::parse_str(&wat).expect("driver double WAT parses");
Component::new(engine, &bytes).expect("driver double compiles")
}
}
#[cfg(test)]
mod tests {
use super::{test_support::driver_double, *};
use crate::bindings::hook::murmur::hook::lifecycle::{ContextInsertion, Message};
use tempfile::TempDir;
const CANNED: &str =
r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"compacted summary"}]}"#;
fn engine() -> Engine {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.epoch_interruption(true);
Engine::new(&config).expect("engine builds")
}
const DECLARED_MODEL: &str = "declared-model";
const DECLARED_CAP: u32 = 1_048_576;
const DECLARED_WINDOW: u32 = 2_000_000;
const DECLARED_CEILING: u32 = 1_000_000;
const PRIMARY_CAP: u32 = 4096;
const LIFECYCLE: HookInferencePurpose = HookInferencePurpose::Lifecycle;
const COMPACTION: HookInferencePurpose = HookInferencePurpose::Compaction;
fn output_caps(compaction: Option<u32>) -> crate::model_limits::HookOutputCaps {
crate::model_limits::HookOutputCaps::new(
std::collections::BTreeMap::from([(
DECLARED_MODEL.to_string(),
murmur_artifact::ModelLimits {
max_output_tokens: DECLARED_CAP,
context_window: DECLARED_WINDOW,
max_input_tokens: Some(DECLARED_CEILING),
vision: None,
},
)]),
PRIMARY_CAP,
compaction,
None,
)
}
fn ctx(engine: &Engine, workdir: &std::path::Path, driver: Component) -> HookInferenceCtx {
ctx_with_spend(engine, workdir, driver, Arc::new(SpendMeter::unlimited()))
}
fn ctx_with_spend(
engine: &Engine,
workdir: &std::path::Path,
driver: Component,
spend: Arc<SpendMeter>,
) -> HookInferenceCtx {
HookInferenceCtx {
driver_name: "mock-driver".to_string(),
driver_component: driver,
model: "manifest-model".to_string(),
output_caps: output_caps(None),
engine: engine.clone(),
accessible_workdir: workdir.to_path_buf(),
records_root: crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
inference_env: Vec::new(),
compaction_inference_env: Vec::new(),
capability_policy: CapabilityPolicy::default(),
network_allow_rules: Vec::new(),
driver_grant: None,
gateway: None,
gateways: Default::default(),
formation: None,
spend,
records: std::sync::Mutex::new(Vec::new()),
spend_refusals: std::sync::Mutex::new(Vec::new()),
retries: std::sync::Mutex::new(Vec::new()),
retry: Default::default(),
deadline: None,
call_deadlines: CallDeadlines::default(),
}
}
fn request(model: Option<&str>) -> InferenceRequest {
InferenceRequest {
messages: vec![Message {
role: "user".to_string(),
content: "summarize this".to_string(),
id: None,
source_id: None,
inserted_by: None,
}],
system_prompt: None,
model: model.map(str::to_string),
}
}
fn expected_input_tokens(model: &str) -> u64 {
expected_input_tokens_at(model, PRIMARY_CAP)
}
fn expected_input_tokens_at(model: &str, cap: u32) -> u64 {
let messages = vec![
serde_json::json!({"role":"user","content":[{"type":"text","text":"summarize this"}]}),
];
let payload = build_driver_payload(model, cap, &messages, &[], "", None, None);
u64::from(count_tokens(&serde_json::to_string(&payload).unwrap()))
}
fn block_on<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Runtime::new().unwrap().block_on(f)
}
#[test]
fn run_inference_model_none_resolves_to_manifest_model() {
let dir = TempDir::new().unwrap();
let engine = engine();
let ctx = ctx(&engine, dir.path(), driver_double(&engine, 0, CANNED));
let resp = block_on(ctx.run("hook:test", LIFECYCLE, request(None)))
.expect("the driver double returns a well-formed completion");
assert_eq!(resp.text, "compacted summary");
assert_eq!(resp.model_used, "manifest-model");
assert_eq!(resp.input_tokens, expected_input_tokens("manifest-model"));
assert_eq!(resp.output_tokens, u64::from(count_tokens(CANNED)));
let records = ctx.drain_records();
assert_eq!(records.len(), 1, "exactly one trace record per call");
assert_eq!(records[0].origin.source, "hook:test");
assert_eq!(records[0].origin.model, "manifest-model");
assert_eq!(records[0].decision, "end_turn");
assert!(ctx.drain_records().is_empty(), "drain is destructive");
}
#[test]
fn run_inference_model_some_overrides_the_sent_model() {
let dir = TempDir::new().unwrap();
let engine = engine();
let ctx = ctx(&engine, dir.path(), driver_double(&engine, 0, CANNED));
let resp = block_on(ctx.run(
"hook:test",
LIFECYCLE,
request(Some("override-model-with-longer-name")),
))
.expect("override model still completes against the double");
assert_eq!(resp.model_used, "override-model-with-longer-name");
assert_eq!(
resp.input_tokens,
expected_input_tokens("override-model-with-longer-name")
);
assert_ne!(
expected_input_tokens("override-model-with-longer-name"),
expected_input_tokens("manifest-model"),
"the two model names must tokenize differently for this test to discriminate"
);
assert_eq!(
ctx.drain_records()[0].origin.model,
"override-model-with-longer-name"
);
}
#[test]
fn run_inference_driver_error_is_err_with_no_retry() {
let dir = TempDir::new().unwrap();
let engine = engine();
let driver = driver_double(
&engine,
0,
r#"{"stop_reason":"error","error":"unknown model: nope"}"#,
);
let ctx = ctx(&engine, dir.path(), driver);
let err = block_on(ctx.run("hook:test", LIFECYCLE, request(Some("nope"))))
.expect_err("a driver-reported error must not be swallowed");
assert!(err.contains("unknown model: nope"), "got: {err}");
let records = ctx.drain_records();
assert_eq!(records.len(), 1, "one attempt, no implicit fallback retry");
assert_eq!(records[0].decision, "error");
assert_eq!(
records[0].origin.model, "nope",
"the failed record must name the model that was actually attempted"
);
}
#[test]
fn run_inference_message_content_is_a_sequence_of_text_blocks() {
let req = InferenceRequest {
messages: vec![
Message {
role: "user".to_string(),
content: "hello".to_string(),
id: None,
source_id: None,
inserted_by: None,
},
Message {
role: "assistant".to_string(),
content: "hi there".to_string(),
id: None,
source_id: None,
inserted_by: None,
},
],
system_prompt: None,
model: None,
};
let messages = wire_messages(&req);
assert_eq!(messages.len(), 2);
for (msg, (role, text)) in messages
.iter()
.zip([("user", "hello"), ("assistant", "hi there")])
{
assert_eq!(msg["role"], role);
let content = msg["content"]
.as_array()
.expect("content must be a sequence of blocks, not a bare string");
assert_eq!(content.len(), 1, "one text block per message");
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], text);
}
}
#[test]
fn message_id_and_source_id_are_stripped_from_a_hooks_inference_request() {
let req = InferenceRequest {
messages: vec![Message {
role: "user".to_string(),
content: "hello".to_string(),
id: Some("msg_0198f1c2d3e44a5b8c9d0e1f2a3b4c5d".to_string()),
source_id: Some("corpus:abc".to_string()),
inserted_by: Some(ContextInsertion::ReplaceContext),
}],
system_prompt: None,
model: None,
};
let messages = wire_messages(&req);
assert_eq!(messages.len(), 1);
let keys: Vec<&String> = messages[0]
.as_object()
.expect("a wire message is a JSON object")
.keys()
.collect();
assert_eq!(keys, vec!["content", "role"]);
let payload = build_driver_payload("m", 8192, &messages, &[], "sys", None, None);
let serialized = serde_json::to_string(&payload).unwrap();
assert!(!serialized.contains("msg_0198f1c2d3e44a5b8c9d0e1f2a3b4c5d"));
assert!(!serialized.contains("corpus:abc"));
assert!(!serialized.contains("source_id"));
assert!(!serialized.contains("source-id"));
assert!(!serialized.contains("inserted_by"));
assert!(!serialized.contains("inserted-by"));
assert!(!serialized.contains("replace-context"));
}
#[test]
fn run_inference_caps_at_the_table_ceiling_else_the_primary_cap() {
let dir = TempDir::new().unwrap();
let engine = engine();
let ctx = ctx(&engine, dir.path(), driver_double(&engine, 0, CANNED));
let declared =
block_on(ctx.run("hook:compact", LIFECYCLE, request(Some(DECLARED_MODEL)))).unwrap();
assert_eq!(
declared.input_tokens,
expected_input_tokens_at(DECLARED_MODEL, DECLARED_CAP)
);
assert_ne!(
expected_input_tokens_at(DECLARED_MODEL, DECLARED_CAP),
expected_input_tokens_at(DECLARED_MODEL, PRIMARY_CAP),
"the two caps must tokenize differently for this test to discriminate"
);
let unlisted = block_on(ctx.run("hook:compact", LIFECYCLE, request(None))).unwrap();
assert_eq!(
unlisted.input_tokens,
expected_input_tokens_at("manifest-model", PRIMARY_CAP)
);
let meter = Arc::new(SpendMeter::new(Some(100_000), None));
let ctx = ctx_with_spend(
&engine,
dir.path(),
driver_double(&engine, 0, CANNED),
Arc::clone(&meter),
);
block_on(ctx.run("hook:compact", LIFECYCLE, request(None)))
.expect("4096 fits under the ceiling");
let err = block_on(ctx.run("hook:compact", LIFECYCLE, request(Some(DECLARED_MODEL))))
.expect_err("the declared ceiling does not");
assert!(err.starts_with("spend ceiling reached"), "{err}");
}
#[test]
fn run_inference_for_compaction_caps_at_the_compaction_cap() {
const COMPACTION_CAP: u32 = 2000;
let dir = TempDir::new().unwrap();
let engine = engine();
let mut ctx = ctx(&engine, dir.path(), driver_double(&engine, 0, CANNED));
ctx.output_caps = output_caps(Some(COMPACTION_CAP));
for (purpose, model, cap) in [
(COMPACTION, DECLARED_MODEL, COMPACTION_CAP),
(COMPACTION, "manifest-model", COMPACTION_CAP),
(LIFECYCLE, DECLARED_MODEL, DECLARED_CAP),
(LIFECYCLE, "manifest-model", PRIMARY_CAP),
] {
let resp = block_on(ctx.run("hook:compact", purpose, request(Some(model)))).unwrap();
assert_eq!(
resp.input_tokens,
expected_input_tokens_at(model, cap),
"{purpose:?} on {model}"
);
}
assert_ne!(
expected_input_tokens_at(DECLARED_MODEL, COMPACTION_CAP),
expected_input_tokens_at(DECLARED_MODEL, DECLARED_CAP),
"the two caps must tokenize differently for this test to discriminate"
);
let unset = self::ctx(&engine, dir.path(), driver_double(&engine, 0, CANNED));
let resp =
block_on(unset.run("hook:compact", COMPACTION, request(Some(DECLARED_MODEL)))).unwrap();
assert_eq!(
resp.input_tokens,
expected_input_tokens_at(DECLARED_MODEL, DECLARED_CAP)
);
let meter = Arc::new(SpendMeter::new(Some(100_000), None));
let mut ctx = ctx_with_spend(
&engine,
dir.path(),
driver_double(&engine, 0, CANNED),
Arc::clone(&meter),
);
ctx.output_caps = output_caps(Some(COMPACTION_CAP));
block_on(ctx.run("hook:compact", COMPACTION, request(Some(DECLARED_MODEL))))
.expect("the compaction cap fits under the ceiling");
let err = block_on(ctx.run("hook:compact", LIFECYCLE, request(Some(DECLARED_MODEL))))
.expect_err("the declared ceiling does not");
assert!(err.starts_with("spend ceiling reached"), "{err}");
}
#[test]
fn run_inference_for_compaction_dispatches_with_the_compaction_env() {
let dir = TempDir::new().unwrap();
let engine = engine();
let mut ctx = ctx(&engine, dir.path(), driver_double(&engine, 0, CANNED));
let session = vec![(
"MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
r#"{"effort":"max"}"#.to_string(),
)];
let compaction = vec![(
"MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
r#"{"effort":"low"}"#.to_string(),
)];
ctx.inference_env = session.clone();
ctx.compaction_inference_env = compaction.clone();
assert_eq!(ctx.env_for(LIFECYCLE), session.as_slice());
assert_eq!(ctx.env_for(COMPACTION), compaction.as_slice());
}
#[test]
fn run_inference_records_each_calls_cap_and_driver_config() {
const COMPACTION_CAP: u32 = 2000;
let config_env = |config: &str| {
vec![(
"MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
config.to_string(),
)]
};
let settings = |cap: u32, budget: Option<u32>, effort: &str| InferenceCallSettings {
max_output_tokens: cap,
input_budget: budget,
driver_config: Some(serde_json::json!({ "reasoning_effort": effort })),
};
let dir = TempDir::new().unwrap();
let engine = engine();
let with_envs = |mut ctx: HookInferenceCtx| {
ctx.output_caps = output_caps(Some(COMPACTION_CAP));
ctx.inference_env = config_env(r#"{"reasoning_effort":"high"}"#);
ctx.compaction_inference_env = config_env(r#"{"reasoning_effort":"low"}"#);
ctx
};
let ctx = with_envs(self::ctx(
&engine,
dir.path(),
driver_double(&engine, 0, CANNED),
));
block_on(ctx.run("hook:compactor", COMPACTION, request(None))).unwrap();
block_on(ctx.run("hook:summary", LIFECYCLE, request(None))).unwrap();
block_on(ctx.run("hook:compactor", COMPACTION, request(Some(DECLARED_MODEL)))).unwrap();
block_on(ctx.run("hook:summary", LIFECYCLE, request(Some(DECLARED_MODEL)))).unwrap();
let recorded: Vec<(String, InferenceCallSettings)> = ctx
.drain_records()
.into_iter()
.map(|record| (record.origin.source, record.settings))
.collect();
assert_eq!(
recorded,
[
(
"hook:compactor".to_string(),
settings(COMPACTION_CAP, None, "low")
),
(
"hook:summary".to_string(),
settings(PRIMARY_CAP, None, "high")
),
(
"hook:compactor".to_string(),
settings(COMPACTION_CAP, Some(DECLARED_CEILING), "low")
),
(
"hook:summary".to_string(),
settings(DECLARED_CAP, Some(DECLARED_WINDOW - DECLARED_CAP), "high")
),
]
);
let failing = with_envs(self::ctx(
&engine,
dir.path(),
driver_double(
&engine,
2,
r#"{"stop_reason":"error","error":"HTTP 500: boom"}"#,
),
));
block_on(failing.run("hook:compactor", COMPACTION, request(None))).expect_err("an error");
let records = failing.drain_records();
assert_eq!(records.len(), 1);
assert!(records[0].failure.is_some());
assert_eq!(records[0].settings, settings(COMPACTION_CAP, None, "low"));
}
#[test]
fn run_inference_refused_by_spend_ceiling() {
let dir = TempDir::new().unwrap();
let engine = engine();
let latched = Arc::new(SpendMeter::new(Some(1_000_000), None));
assert!(latched.admit(2_000_000, 0).is_err(), "latches the session");
for (case, meter) in [
("too small", Arc::new(SpendMeter::new(Some(10), None))),
("latched", latched),
] {
let ctx = ctx_with_spend(
&engine,
dir.path(),
driver_double(&engine, 2, "the driver was invoked"),
Arc::clone(&meter),
);
let err = block_on(ctx.run("hook:compact", LIFECYCLE, request(None)))
.expect_err("a refused call is an err");
assert!(
err.starts_with("spend ceiling reached: inference.max_session_tokens is"),
"{case}: {err}"
);
assert!(
err.contains("retrying will not get past it"),
"{case}: {err}"
);
assert!(!err.contains("the driver was invoked"), "{case}: {err}");
assert!(
ctx.drain_records().is_empty(),
"{case}: no inference record"
);
let refusals = ctx.drain_spend_refusals();
assert_eq!(refusals.len(), 1, "{case}");
assert_eq!(refusals[0].origin, "hook:compact", "{case}");
assert_eq!(
refusals[0].refusal.limit,
crate::spend::SpendLimit::Session,
"{case}"
);
assert_eq!(meter.used(), 0, "{case}: nothing was charged");
assert!(!meter.has_open_admission(), "{case}");
}
}
#[test]
fn run_inference_driver_error_status_records_its_failure() {
let dir = TempDir::new().unwrap();
let engine = engine();
let response = r#"{"stop_reason":"error","error":"HTTP 500: boom"}"#;
let ctx = ctx(&engine, dir.path(), driver_double(&engine, 2, response));
let err =
block_on(ctx.run("hook:compactor", LIFECYCLE, request(None))).expect_err("an error");
assert_eq!(
err,
format!("inference driver returned an error: {response}")
);
let records = ctx.drain_records();
assert_eq!(records.len(), 1);
let record = &records[0];
assert_eq!(record.origin.source, "hook:compactor");
assert_eq!(record.origin.model, "manifest-model");
assert_eq!(record.decision, "error");
assert_eq!(record.input_tokens, expected_input_tokens("manifest-model"));
assert_eq!(record.output_tokens, None);
let failure = record
.failure
.as_ref()
.expect("a failed call names its failure");
assert_eq!(failure.code, "driver_error");
assert_eq!(failure.message, err);
assert!(
failure.error.contains("HTTP 500: boom"),
"{}",
failure.error
);
assert_eq!(
failure.provider_status, None,
"the context holds no gateway"
);
}
#[test]
fn run_inference_charges_the_reported_counts() {
let dir = TempDir::new().unwrap();
let engine = engine();
let charged = |response: &str| {
let meter = Arc::new(SpendMeter::new(Some(1_000_000), None));
let ctx = ctx_with_spend(
&engine,
dir.path(),
driver_double(&engine, 0, response),
Arc::clone(&meter),
);
let resp = block_on(ctx.run("hook:compact", LIFECYCLE, request(None))).unwrap();
(meter.used(), resp)
};
let reporting = r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"s"}],"usage":{"input_tokens":70,"output_tokens":12,"cached_tokens":30,"cache_write_tokens":3,"thinking_tokens":5}}"#;
let (used, resp) = charged(reporting);
assert_eq!(used, 70 + 30 + 3 + 12);
assert_eq!(
resp.input_tokens,
expected_input_tokens("manifest-model"),
"the WIT counts stay the runtime's own"
);
let (used, _) = charged(CANNED);
assert_eq!(
used,
expected_input_tokens("manifest-model") + u64::from(count_tokens(CANNED))
);
let input_only = r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"s"}],"usage":{"input_tokens":70}}"#;
let (used, _) = charged(input_only);
assert_eq!(
used,
expected_input_tokens("manifest-model") + u64::from(count_tokens(input_only))
);
}
#[test]
fn run_inference_charges_a_failed_call_its_reported_counts() {
let dir = TempDir::new().unwrap();
let engine = engine();
let charged = |response: &str| {
let meter = Arc::new(SpendMeter::new(Some(1_000_000), None));
let ctx = ctx_with_spend(
&engine,
dir.path(),
driver_double(&engine, 2, response),
Arc::clone(&meter),
);
block_on(ctx.run("hook:compact", LIFECYCLE, request(None))).expect_err("a failed call");
meter.used()
};
assert_eq!(
charged(
r#"{"stop_reason":"error","error":"HTTP 500","usage":{"input_tokens":40,"output_tokens":2,"cached_tokens":8}}"#
),
40 + 8 + 2
);
assert_eq!(
charged(r#"{"stop_reason":"error","error":"HTTP 500"}"#),
expected_input_tokens("manifest-model")
);
}
#[test]
fn run_inference_failed_driver_status_is_err() {
let dir = TempDir::new().unwrap();
let engine = engine();
let ctx = ctx(&engine, dir.path(), driver_double(&engine, 2, "boom"));
let err = block_on(ctx.run("hook:test", LIFECYCLE, request(None)))
.expect_err("status: error must surface");
assert!(err.contains("boom"), "got: {err}");
assert_eq!(ctx.drain_records()[0].decision, "error");
}
}