From f3d1904428a31960a6cbaba3e8e440c31317bdcc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 11 Sep 2026 00:41:27 +0200 Subject: [PATCH] Let the smoke show the whole membrane A test-only TracingInferencePort wraps any inference port and appends each request's purpose, model, instructions, input turns, tool catalog, and output to the file GHOSTLIGHT_SMOKE_TRACE names. The logs redact prose by design and the store is a temp directory, so this is the one way to read what the Projector, the Persona, and the Interpreter were each given and what each returned on a road run. Co-Authored-By: Claude Fable 5.1 --- crates/ghostlight-dungeon/src/runtime.rs | 9 ++ .../src/world/controllers.rs | 100 ++++++++++++++++++ crates/ghostlight-dungeon/src/world/mod.rs | 4 +- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 933d140..c7fee33 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -2477,6 +2477,9 @@ mod tests { /// instead of, or alongside, the connector. sdk: Option, models: ControllerModels, + /// Where the whole membrane is written, request by request, when the + /// smoke is asked to show it. + trace: Option, } async fn fixture() -> Fixture { @@ -2516,8 +2519,13 @@ mod tests { operational_agent: "gpt-5.6-terra".into(), elaborator: "gpt-5.6-terra".into(), }, + trace: None, }); let inference = open_inference(live.connector, live.sdk, &live.models).unwrap(); + let inference: Arc = match live.trace { + Some(path) => Arc::new(crate::world::TracingInferencePort::new(inference, path)), + None => inference, + }; let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap(); let controllers = ControllerRunner::open(world.clone(), inference, work, live.models).unwrap(); @@ -3634,6 +3642,7 @@ mod tests { operational_agent: env("GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL"), elaborator: env("GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL"), }, + trace: std::env::var_os("GHOSTLIGHT_SMOKE_TRACE").map(PathBuf::from), }; let ticks: u32 = env("GHOSTLIGHT_SMOKE_TICKS").parse().unwrap(); let log_path = PathBuf::from(env("GHOSTLIGHT_SMOKE_LOG")); diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index 06a2923..8ca3cfd 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -268,6 +268,106 @@ pub(crate) trait InferencePort: Send + Sync { } } +/// Test-only. Wraps any `InferencePort` and appends every prepared request and +/// its output to one file, so a road run can show the whole membrane: what the +/// Projector, the Persona, and the Interpreter were each given and what each +/// returned. Production never constructs it; prose reaches durable state only +/// as receipt-bound evidence, and the logs redact it by design. +#[cfg(test)] +pub(crate) struct TracingInferencePort { + inner: Arc, + path: PathBuf, +} + +#[cfg(test)] +impl TracingInferencePort { + pub(crate) fn new(inner: Arc, path: PathBuf) -> Self { + Self { inner, path } + } + + fn render_request(prepared: &PreparedInference) -> String { + let request = &prepared.invocation.request; + let mut out = format!( + "\n==== {:?} model={} request={}\n", + prepared.purpose, request.model, request.request_id + ); + if !request.instructions.is_empty() { + out.push_str(&format!("-- instructions --\n{}\n", request.instructions)); + } + out.push_str("-- input --\n"); + for item in &request.input { + match item { + CodexInputItem::UserText { text } => out.push_str(&format!("[user]\n{text}\n")), + CodexInputItem::AssistantText { text } => { + out.push_str(&format!("[assistant]\n{text}\n")); + } + CodexInputItem::ToolCall { + name, arguments, .. + } => out.push_str(&format!("[tool call {name}]\n{arguments}\n")), + CodexInputItem::ToolResult { output, .. } => { + out.push_str(&format!("[tool result]\n{output}\n")); + } + } + } + if !request.tools.is_empty() { + out.push_str("-- tools --\n"); + for tool in &request.tools { + out.push_str(&format!( + "{}: {}\n{}\n", + tool.name, tool.description, tool.parameters_json + )); + } + } + out + } + + fn render_output(result: &Result) -> String { + let mut out = String::from("-- output --\n"); + match result { + Ok(output) => { + for event in &output.events { + match event { + InferenceEvent::Text(text) => out.push_str(&format!("[text]\n{text}\n")), + InferenceEvent::ToolCall { + name, arguments, .. + } => out.push_str(&format!("[tool call {name}]\n{arguments}\n")), + } + } + } + Err(fault) => out.push_str(&format!("[fault]\n{}\n", fault.detail)), + } + out + } +} + +#[cfg(test)] +#[async_trait] +impl InferencePort for TracingInferencePort { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + let rendered = Self::render_request(&request); + let result = self.inner.infer(request).await; + let mut text = rendered; + text.push_str(&Self::render_output(&result)); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + use std::io::Write as _; + let _ = file.write_all(text.as_bytes()); + } + result + } + + fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box) { + self.inner.lend_tool_results(prepared, oracle); + } +} + /// Builds a `PreparedInference` outside the real CodexConnector wiring. Exists /// so a test port defined outside this module (`runtime`'s own spec tests, for /// the tick driver's concurrency and quarantine behaviour) has one legal way to diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index e2bb62b..08a3963 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -66,8 +66,8 @@ pub(crate) use vault::VaultEvidenceSource; pub(crate) use controllers::{ ControllerWork, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite, InferenceEvent, InferenceFault, InferenceOutput, InferencePort, - InferencePurpose, InferenceRequest, PreparedInference, fixture_inference_events, - fixture_inference_output, fixture_prepared_inference, + InferencePurpose, InferenceRequest, PreparedInference, TracingInferencePort, + fixture_inference_events, fixture_inference_output, fixture_prepared_inference, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch, Site};