Repair model-turn JSON ingress

This commit is contained in:
Meta Vens 2026-08-10 02:39:10 +01:00
commit eea15e82e6
4 changed files with 299 additions and 19 deletions

View file

@ -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::<EpiphanyModelRequest>(&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<PathBuf>,
}
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<ModelInputItemJson>,
reasoning_effort: Option<String>,
reasoning_summary: Option<String>,
service_tier: Option<String>,
output_contract_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OpenAiModelRequestJson {
schema_id: String,
request_id: String,
conversation_id: String,
model: String,
instructions: String,
input: Vec<ModelInputItemJson>,
reasoning_effort: Option<String>,
reasoning_summary: Option<String>,
service_tier: Option<String>,
output_contract_id: Option<String>,
}
#[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<ModelTurnJsonRequest> {
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::EpiphanyToolInvocationReceipt>(
&epiphany_tool_adapter::tool_invocation_receipt_key(
&openai_summary.tool_intent_ids[0]
)
)?
.is_some());
assert!(
cache
.get::<epiphany_tool_adapter::EpiphanyToolInvocationReceipt>(
&epiphany_tool_adapter::tool_invocation_receipt_key(
&openai_summary.tool_intent_ids[0]
)
)?
.is_some()
);
Ok(())
}

View file

@ -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

View file

@ -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.

View file

@ -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.