From eea15e82e644600aa7bdcd340b06f98d8d629973 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 10 Aug 2026 02:39:10 +0100 Subject: [PATCH] Repair model-turn JSON ingress --- .../src/bin/epiphany-openai-runtime.rs | 269 +++++++++++++++++- notes/epiphany-current-algorithmic-map.md | 23 ++ notes/fresh-workspace-handoff.md | 18 ++ state/map.yaml | 8 +- 4 files changed, 299 insertions(+), 19 deletions(-) diff --git a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs index 7d067207..e0df1a28 100644 --- a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs +++ b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs @@ -15,10 +15,13 @@ use anyhow::Result; use anyhow::anyhow; use epiphany_model_adapter::EpiphanyModelInputItem; use epiphany_model_adapter::EpiphanyModelRequest; +use epiphany_model_adapter::MODEL_ADAPTER_REQUEST_SCHEMA_ID; +use epiphany_openai_adapter::EpiphanyOpenAiInputItem; use epiphany_openai_adapter::EpiphanyOpenAiModelReceipt; use epiphany_openai_adapter::EpiphanyOpenAiModelRequest; use epiphany_openai_adapter::EpiphanyOpenAiStreamEvent; use epiphany_openai_adapter::EpiphanyOpenAiStreamPayload; +use epiphany_openai_adapter::OPENAI_ADAPTER_REQUEST_SCHEMA_ID; use epiphany_openai_runtime::EpiphanyOpenAiRuntimeOptions; use epiphany_openai_runtime::EpiphanyWorkerRuntimeOptions; use epiphany_openai_runtime::OPENAI_RUNTIME_ROLE; @@ -40,6 +43,7 @@ use epiphany_openai_runtime::run_worker_launch; use epiphany_openai_runtime::worker_model_session_id; use epiphany_tool_adapter::EpiphanyToolInvocationIntent; use epiphany_tool_adapter::tool_invocation_intent_key; +use serde::Deserialize; use serde_json::json; use sha2::Digest; use sha2::Sha256; @@ -118,23 +122,23 @@ async fn main() -> Result<()> { let request_text = fs::read_to_string(&options.request_path) .with_context(|| format!("failed to read {}", options.request_path.display()))?; let output_last_message_path = options.output_last_message_path.clone(); - let (request_id, runtime_options, summary) = - if let Ok(request) = serde_json::from_str::(&request_text) { + let request = parse_model_turn_request_json(&request_text) + .with_context(|| format!("failed to parse {}", options.request_path.display()))?; + let (request_id, runtime_options, summary) = match request { + ModelTurnJsonRequest::Native(request) => { let runtime_options = options.clone().into_runtime_options_for_model(&request); let summary = run_model_turn(&options.provider, runtime_options.clone(), request.clone()) .await?; (request.request_id, runtime_options, summary) - } else { - let request: EpiphanyOpenAiModelRequest = serde_json::from_str(&request_text) - .with_context(|| { - format!("failed to parse {}", options.request_path.display()) - })?; + } + ModelTurnJsonRequest::OpenAi(request) => { let runtime_options = options.into_runtime_options(&request); let summary = run_openai_model_turn(runtime_options.clone(), request.clone()).await?; (request.request_id, runtime_options, summary) - }; + } + }; if let Some(path) = output_last_message_path { let text = assistant_text_from_model_events(&runtime_options.store_path, &request_id)?; @@ -329,6 +333,150 @@ struct ModelTurnCliOptions { output_last_message_path: Option, } +enum ModelTurnJsonRequest { + Native(EpiphanyModelRequest), + OpenAi(EpiphanyOpenAiModelRequest), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeModelRequestJson { + schema_id: String, + request_id: String, + conversation_id: String, + provider: String, + model: String, + instructions: String, + input: Vec, + reasoning_effort: Option, + reasoning_summary: Option, + service_tier: Option, + output_contract_id: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenAiModelRequestJson { + schema_id: String, + request_id: String, + conversation_id: String, + model: String, + instructions: String, + input: Vec, + reasoning_effort: Option, + reasoning_summary: Option, + service_tier: Option, + output_contract_id: Option, +} + +#[derive(Deserialize)] +enum ModelInputItemJson { + UserText(ModelTextJson), + AssistantText(ModelTextJson), + ToolResult(ModelToolResultJson), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelTextJson { + text: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelToolResultJson { + call_id: String, + output: String, +} + +impl ModelInputItemJson { + fn into_native(self) -> EpiphanyModelInputItem { + match self { + Self::UserText(item) => EpiphanyModelInputItem::UserText { text: item.text }, + Self::AssistantText(item) => EpiphanyModelInputItem::AssistantText { text: item.text }, + Self::ToolResult(item) => EpiphanyModelInputItem::ToolResult { + call_id: item.call_id, + output: item.output, + }, + } + } + + fn into_openai(self) -> EpiphanyOpenAiInputItem { + match self { + Self::UserText(item) => EpiphanyOpenAiInputItem::UserText { text: item.text }, + Self::AssistantText(item) => EpiphanyOpenAiInputItem::AssistantText { text: item.text }, + Self::ToolResult(item) => EpiphanyOpenAiInputItem::ToolResult { + call_id: item.call_id, + output: item.output, + }, + } + } +} + +fn parse_model_turn_request_json(text: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(text).context("model-turn request is not valid JSON")?; + let schema_id = value + .as_object() + .and_then(|object| object.get("schema_id")) + .and_then(serde_json::Value::as_str) + .context("model-turn request must be an object with string schema_id")?; + match schema_id { + MODEL_ADAPTER_REQUEST_SCHEMA_ID => { + let request: NativeModelRequestJson = serde_json::from_value(value) + .context("model-turn native request violates epiphany.model_request.v0")?; + anyhow::ensure!( + request.schema_id == MODEL_ADAPTER_REQUEST_SCHEMA_ID, + "native model request schema_id is invalid" + ); + let mut typed = EpiphanyModelRequest::new( + request.request_id, + request.conversation_id, + request.provider, + request.model, + request.instructions, + ); + typed.input = request + .input + .into_iter() + .map(ModelInputItemJson::into_native) + .collect(); + typed.reasoning_effort = request.reasoning_effort; + typed.reasoning_summary = request.reasoning_summary; + typed.service_tier = request.service_tier; + typed.output_contract_id = request.output_contract_id; + Ok(ModelTurnJsonRequest::Native(typed)) + } + OPENAI_ADAPTER_REQUEST_SCHEMA_ID => { + let request: OpenAiModelRequestJson = serde_json::from_value(value) + .context("model-turn provider request violates epiphany.openai_model_request.v0")?; + anyhow::ensure!( + request.schema_id == OPENAI_ADAPTER_REQUEST_SCHEMA_ID, + "OpenAI model request schema_id is invalid" + ); + let mut typed = EpiphanyOpenAiModelRequest::new( + request.request_id, + request.conversation_id, + request.model, + request.instructions, + ); + typed.input = request + .input + .into_iter() + .map(ModelInputItemJson::into_openai) + .collect(); + typed.reasoning_effort = request.reasoning_effort; + typed.reasoning_summary = request.reasoning_summary; + typed.service_tier = request.service_tier; + typed.output_contract_id = request.output_contract_id; + Ok(ModelTurnJsonRequest::OpenAi(typed)) + } + other => Err(anyhow!( + "unsupported model-turn request schema_id {other:?}" + )), + } +} + impl ModelTurnCliOptions { fn into_runtime_options_for_model( self, @@ -992,6 +1140,95 @@ mod tests { use epiphany_tool_adapter::EpiphanyToolInvocationIntent; use tempfile::tempdir; + #[test] + fn model_turn_json_ingress_accepts_published_native_object() -> Result<()> { + let request = parse_model_turn_request_json( + r#"{ + "schema_id":"epiphany.model_request.v0", + "request_id":"request-json-native", + "conversation_id":"conversation-json-native", + "provider":"openai-codex", + "model":"gpt-test", + "instructions":"Return awake.", + "input":[{"UserText":{"text":"awake"}}], + "reasoning_effort":"low" + }"#, + )?; + let ModelTurnJsonRequest::Native(request) = request else { + panic!("native schema selected provider request"); + }; + assert_eq!(request.request_id, "request-json-native"); + assert_eq!(request.reasoning_effort.as_deref(), Some("low")); + assert_eq!(request.input.len(), 1); + assert!(request.tools.is_empty()); + Ok(()) + } + + #[test] + fn model_turn_json_ingress_accepts_published_provider_object() -> Result<()> { + let request = parse_model_turn_request_json( + r#"{ + "schema_id":"epiphany.openai_model_request.v0", + "request_id":"request-json-provider", + "conversation_id":"conversation-json-provider", + "model":"gpt-test", + "instructions":"Return awake.", + "input":[{"ToolResult":{"call_id":"call-1","output":"awake"}}], + "output_contract_id":"contract-1" + }"#, + )?; + let ModelTurnJsonRequest::OpenAi(request) = request else { + panic!("provider schema selected native request"); + }; + assert_eq!(request.request_id, "request-json-provider"); + assert_eq!(request.output_contract_id.as_deref(), Some("contract-1")); + assert_eq!(request.input.len(), 1); + assert!(request.tools.is_empty()); + Ok(()) + } + + #[test] + fn model_turn_json_ingress_refuses_persistence_arrays_and_unknown_fields() { + assert!(parse_model_turn_request_json(r#"["epiphany.model_request.v0"]"#).is_err()); + assert!( + parse_model_turn_request_json( + r#"{ + "schema_id":"epiphany.model_request.v0", + "request_id":"request-json-hostile", + "conversation_id":"conversation-json-hostile", + "provider":"openai-codex", + "model":"gpt-test", + "instructions":"Return awake.", + "input":[], + "reasoning_effort":null, + "reasoning_summary":null, + "service_tier":null, + "output_contract_id":null, + "persistence_payload":"forbidden" + }"#, + ) + .is_err() + ); + assert!( + parse_model_turn_request_json( + r#"{ + "schema_id":"epiphany.model_request.v0", + "request_id":"request-json-toolcall", + "conversation_id":"conversation-json-toolcall", + "provider":"openai-codex", + "model":"gpt-test", + "instructions":"Return awake.", + "input":[{"ToolCall":{"call_id":"call-1","name":"x","arguments":"{}"}}], + "reasoning_effort":null, + "reasoning_summary":null, + "service_tier":null, + "output_contract_id":null + }"#, + ) + .is_err() + ); + } + fn seed_pending_tool_summary( store: &Path, worker_job_id: &str, @@ -1209,13 +1446,15 @@ mod tests { .status, epiphany_core::EpiphanyRuntimeSessionStatus::Completed ); - assert!(cache - .get::( - &epiphany_tool_adapter::tool_invocation_receipt_key( - &openai_summary.tool_intent_ids[0] - ) - )? - .is_some()); + assert!( + cache + .get::( + &epiphany_tool_adapter::tool_invocation_receipt_key( + &openai_summary.tool_intent_ids[0] + ) + )? + .is_some() + ); Ok(()) } diff --git a/notes/epiphany-current-algorithmic-map.md b/notes/epiphany-current-algorithmic-map.md index 3e839b3c..54de75cc 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -2487,6 +2487,29 @@ that rejects unknown/malformed fields and converts into the typed persisted request. CultCache serialization remains the persistence owner; JSON does not get to reshape it. +The implemented CLI boundary now owns JSON syntax only. It first requires an +object with an exact string `schema_id`, then deserializes into one of two +`deny_unknown_fields` DTOs matching the published native and OpenAI request +schemas. Their three published input variants convert into the corresponding +typed adapter request. The adapter request then enters the unchanged runtime and +CultCache persistence path. + +- Owner: the published CultNet JSON schema owns accepted boundary shape; the + typed adapter request owns runtime meaning; DatabaseEntry owns persistence. +- Inputs: one native or provider object with the exact schema ID and published + fields. +- Output: an `EpiphanyModelRequest` or `EpiphanyOpenAiModelRequest` with + persistence-only fields left at typed defaults. +- Forbidden cargo: positional CultCache arrays, unknown top-level or variant + fields, malformed variants, unsupported schema IDs, and `ToolCall` until a + published contract owns it. +- Shared path: both native and provider objects converge on the existing model + runtime after conversion; neither can deserialize persistence representation + directly. +- Verification: all nine binary tests pass, including both positive object + shapes and negative array/unknown/ToolCall cases. Exact package inspection and + copied-live execution remain required before acceptance. + The coordinator receipt accumulator is implemented in source. Runtime owns the typed head, cumulative status counts, chained digest, and full-snapshot-fenced replacement/deletion. Resident Self supplies the cross-store preservation set diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 09032124..05b4b417 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1589,3 +1589,21 @@ DatabaseEntry positional-array representation. Two object-shaped requests failed before store mutation; only the native CultCache array shape passed. Repair this xenos JSON boundary with an explicit schema-shaped ingress type and typed conversion. Do not change CultCache persistence to accommodate JSON. + +## Model-turn JSON boundary implemented — 2026-08-10 + +`epiphany-model-runtime model-turn` now parses the two published object-shaped +contracts through explicit boundary DTOs and dispatches by exact `schema_id`. +The boundary accepts only the fields and input variants published by +`epiphany.model_request.v0` and `epiphany.openai_model_request.v0`, then converts +them into the existing typed requests. Positional CultCache arrays, unknown +fields, malformed variants, and unpublished `ToolCall` cargo refuse before the +runtime path. DatabaseEntry serialization remains solely the persistence +authority. + +All nine model-runtime binary tests pass, including published native/provider +object acceptance and the negative boundary cases. Production `cargo check` +also passes warning-free. The source is not accepted until its exact pushed +commit is packaged from Starfire's warm cache and a copied-live run proves one +published object completes while the old positional array cannot mutate state. +Active c005 remains outside this rite; Yggdrasil never builds. diff --git a/state/map.yaml b/state/map.yaml index 6ae89708..98f4afdc 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -36,9 +36,9 @@ invariants: current_status: summary: Epiphany is a supervised engineering alpha on Starfire, not deployment-ready. Exact b8138a6b active-child SIGTERM and restart, exact 9f369528 deterministic warm packaging, exact c930f505 cross-container ownership, exact ed15f83a resident lifecycle retention, exact 16c4b19f coordinator receipt and Heartbeat artifact retention, and exact cce2b8ac narrow model-generation retention are proven. Gamecult-ops 059821f makes journald the sole Yggdrasil supervisor/projector stream-retention owner. Exact 16c4b19f genuine idle and exact a987d2f7 active cognition completed 900-pulse bounded plateaus. Runtime-owned typed fulfillment is accepted live under exact 6dd9b132. Stable Starfire firewall ownership and broader semantic/conversation retention remain open. Yggdrasil is the small public crossing body, never the release forge. - latest_phase: Exact cce2b8ac packaged warning-free in 20.70s warm with 24 binaries, release sha256-1f29f33c495e8ba79ce8e23c1ac923c4ffcd5d86c97632a17d44566669de481e, and witness sha256-25e48f9c79c51cd9853e826e2f9dfdb3b2570006fd37138fd36ac1555bc075e7. On copied live state, two genuine direct-OpenAI model generations completed and closed. Packaged Self with retention window one replaced the older generation's exact 29-envelope family with one ID tombstone digest sha256:fc3658898d383333edff411f64999af7890cdeaf8fdce737589bc5667f65db33, preserved the newer generation, left pending tools zero, refused archived session recreation, and replayed with an identical runtime SHA-256. All 1,427 pre-existing envelopes survived; only two producer-status rows changed from the model calls and no pre-existing authority row changed. The proof exposed that model-turn JSON object ingress contradicts its published schema because DatabaseEntry serde expects a positional CultCache array. + latest_phase: The model-turn xenos boundary now has explicit schema-shaped DTOs for the published epiphany.model_request.v0 and epiphany.openai_model_request.v0 objects. It dispatches by schema_id, converts only published fields into the typed request, rejects positional CultCache arrays, unknown fields, malformed input variants, and unpublished ToolCall cargo, and leaves DatabaseEntry serialization solely on the persistence side. All nine model-runtime binary tests and a production cargo check pass. Exact packaging and copied-live object ingress remain the acceptance boundary. operational_state: - local_worktree: archived_model_session_packaged_live_proven_schema_boundary_fault_open + local_worktree: model_turn_json_boundary_implemented_tests_green_uncommitted operator_wireguard: restored yggdrasil: canonical_small_public_crossing_body starfire: temporary_epiphany_cognition_body @@ -68,8 +68,8 @@ current_status: starfire_linux_modeling_peak: 109887488_bytes_resident_body_plus_modeling_circuit yggdrasil_epiphany_service_contract: repaired_in_gamecult_ops_812b427_not_installed resident_physiology: authenticated_9b8df0b4_c005_bound_to_eyes_tools_nervous_system_with_217dd_and_bd96305e_helpers_running - unresolved_authority: Coordinator receipt, Heartbeat pulse-artifact, deployed supervisor/projector log retention, genuine-idle and active resource plateaus, exact-grant terminality, restart-persistent cooldown refusal, schedule/action issuance cardinality, runtime-owned typed fulfillment, and narrow model-generation retention are accepted. Semantic and conversation-wide runtime histories remain separate/unbounded. The model-turn CLI's JSON object contract is false against its published schema and currently accepts only the CultCache positional array. Repository Body observation still performs two authenticated full-tree Git index/manifest passes. Execute-mode workers require a persistent process body. Keyed storage has not earned live migration. One Discord consequence remains blocked by the absent Yggdrasil credential. - next_action: Repair model-turn JSON ingress at the xenos boundary without changing CultCache persistence: parse the published object-shaped epiphany.model_request.v0 contract into the typed request, retain explicit rejection of malformed/unknown fields, and test object acceptance plus positional-array refusal or deliberate quarantined compatibility. Then package and live-smoke the exact boundary before continuing semantic retention/endurance. Preserve active c005. Yggdrasil never builds. + unresolved_authority: Coordinator receipt, Heartbeat pulse-artifact, deployed supervisor/projector log retention, genuine-idle and active resource plateaus, exact-grant terminality, restart-persistent cooldown refusal, schedule/action issuance cardinality, runtime-owned typed fulfillment, and narrow model-generation retention are accepted. Semantic and conversation-wide runtime histories remain separate/unbounded. The repaired model-turn JSON object contract is source- and test-proven but not yet accepted under an exact packaged copied-live run. Repository Body observation still performs two authenticated full-tree Git index/manifest passes. Execute-mode workers require a persistent process body. Keyed storage has not earned live migration. One Discord consequence remains blocked by the absent Yggdrasil credential. + next_action: Commit and push the explicit model-turn JSON boundary, package and authenticate that exact source using Starfire's persistent cache, then use copied state to prove a published object-shaped request completes while the positional CultCache array refuses before mutation. Preserve active c005. Yggdrasil never builds. active_subgoals: - Prove Persona, Self, Eyes, Modeling, Imagination, Hands, Soul, Mind, Continuity, and nervous-system behavior individually.