Preserve Persona decision contexts through retention
This commit is contained in:
parent
9036aaa6bb
commit
833d39647c
5 changed files with 305 additions and 66 deletions
|
|
@ -960,6 +960,7 @@ pub use persona_turn::build_persona_turn_prompt;
|
|||
pub use persona_turn::parse_and_validate_persona_interpreter_effect_set;
|
||||
pub use persona_turn::persona_interpreter_effect_set_json_schema;
|
||||
pub use persona_turn::persona_projected_surface_is_clean;
|
||||
pub use persona_turn::put_persona_terminal_decision;
|
||||
pub use persona_turn::render_persona_semantic_memory_recall;
|
||||
pub use persona_turn::semantic_memory_recall_from_heartbeat_action;
|
||||
pub use process_observation::EpiphanyProcessObservation;
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ use crate::heartbeat_state::{
|
|||
load_heartbeat_state_transaction,
|
||||
};
|
||||
use crate::{
|
||||
AgentSelfPatch, EpiphanyPersonaDeliveryRequestIdentity, PersonaInterpreterEffect,
|
||||
PersonaInterpreterEffectDocument, PersonaModelStageReceipt, PersonaModelTerminalReceipt,
|
||||
PersonaTurnRequest, PersonaTurnTerminalOptions, apply_agent_self_patch_document,
|
||||
AgentSelfPatch, EpiphanyDecisionContext, EpiphanyPersonaDeliveryRequestIdentity,
|
||||
EpiphanyReasoningBasis, PersonaInterpreterEffect, PersonaInterpreterEffectDocument,
|
||||
PersonaModelStageReceipt, PersonaModelTerminalReceipt, PersonaTurnRequest,
|
||||
PersonaTurnTerminalOptions, apply_agent_self_patch_document,
|
||||
complete_persona_turn_request_store, insert_persona_discord_delivery_request,
|
||||
load_persona_discord_delivery_receipt, runtime_spine_cache,
|
||||
sign_persona_discord_delivery_request, verify_persona_discord_delivery_receipt,
|
||||
|
|
@ -116,6 +117,10 @@ pub struct PersonaConversationExecutionReceipt {
|
|||
pub heartbeat_terminal_receipt_id: Option<String>,
|
||||
#[cultcache(key = 9)]
|
||||
pub private_state_exposed: bool,
|
||||
#[cultcache(key = 10)]
|
||||
pub model_terminal_receipt_id: Option<String>,
|
||||
#[cultcache(key = 11)]
|
||||
pub interpreter_decision_context_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Advances one reserved Persona turn across the signed Epiphany→Bifrost
|
||||
|
|
@ -144,7 +149,7 @@ pub fn poll_persona_discord_crossing(
|
|||
let effects =
|
||||
runtime_document::<PersonaInterpreterEffectDocument>(runtime_store, effect_document_id)?
|
||||
.ok_or_else(|| anyhow!("Persona Interpreter effect document is missing"))?;
|
||||
validate_model_terminal(runtime_store, &request, &effects)?;
|
||||
let model_terminal = validate_model_terminal(runtime_store, &request, &effects)?;
|
||||
require_persona_effects_unbraked(cultmesh_store, runtime_id)?;
|
||||
let say = effects
|
||||
.effects
|
||||
|
|
@ -214,6 +219,8 @@ pub fn poll_persona_discord_crossing(
|
|||
delivery_evidence_ids: vec![],
|
||||
heartbeat_terminal_receipt_id: Some(terminal.receipt_id),
|
||||
private_state_exposed: false,
|
||||
model_terminal_receipt_id: Some(model_terminal.receipt_id.clone()),
|
||||
interpreter_decision_context_id: Some(effects.decision_context_id.clone()),
|
||||
};
|
||||
put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?;
|
||||
return Ok(Some(receipt));
|
||||
|
|
@ -337,6 +344,8 @@ pub fn poll_persona_discord_crossing(
|
|||
.collect(),
|
||||
heartbeat_terminal_receipt_id: Some(terminal.receipt_id),
|
||||
private_state_exposed: false,
|
||||
model_terminal_receipt_id: Some(model_terminal.receipt_id.clone()),
|
||||
interpreter_decision_context_id: Some(effects.decision_context_id.clone()),
|
||||
};
|
||||
put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?;
|
||||
Ok(Some(receipt))
|
||||
|
|
@ -349,6 +358,7 @@ fn terminalize_local_effect_quarantine(
|
|||
effects: &PersonaInterpreterEffectDocument,
|
||||
error: anyhow::Error,
|
||||
) -> Result<Option<PersonaConversationExecutionReceipt>> {
|
||||
let model_terminal = validate_model_terminal(runtime_store, request, effects)?;
|
||||
let terminal = complete_persona_turn_request_store(
|
||||
heartbeat_store,
|
||||
PersonaTurnTerminalOptions {
|
||||
|
|
@ -375,6 +385,8 @@ fn terminalize_local_effect_quarantine(
|
|||
delivery_evidence_ids: vec![],
|
||||
heartbeat_terminal_receipt_id: Some(terminal.receipt_id),
|
||||
private_state_exposed: false,
|
||||
model_terminal_receipt_id: Some(model_terminal.receipt_id),
|
||||
interpreter_decision_context_id: Some(effects.decision_context_id.clone()),
|
||||
};
|
||||
put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?;
|
||||
Ok(Some(receipt))
|
||||
|
|
@ -410,7 +422,7 @@ pub fn reconcile_terminal_persona_conversation(
|
|||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_model_terminal(runtime_store, &request, &effects)?;
|
||||
let model_terminal = validate_model_terminal(runtime_store, &request, &effects)?;
|
||||
for index in 0..effects.effects.len() {
|
||||
let intent_id = format!("persona-effect-intent:{}:{index}", effects.document_id);
|
||||
if let Some(mut intent) =
|
||||
|
|
@ -432,6 +444,8 @@ pub fn reconcile_terminal_persona_conversation(
|
|||
delivery_evidence_ids: terminal.delivery_evidence_id.clone().into_iter().collect(),
|
||||
heartbeat_terminal_receipt_id: Some(terminal.receipt_id.clone()),
|
||||
private_state_exposed: false,
|
||||
model_terminal_receipt_id: Some(model_terminal.receipt_id),
|
||||
interpreter_decision_context_id: Some(effects.decision_context_id.clone()),
|
||||
};
|
||||
put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?;
|
||||
Ok(Some(receipt))
|
||||
|
|
@ -595,10 +609,13 @@ fn build_persona_retention_member(
|
|||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let model_terminal_id = format!("persona-terminal:{}", request.request_id);
|
||||
if conversation.request_id != request.request_id
|
||||
|| conversation.outcome != terminal.outcome
|
||||
|| conversation.heartbeat_terminal_receipt_id.as_deref()
|
||||
!= Some(terminal.receipt_id.as_str())
|
||||
|| conversation.model_terminal_receipt_id.as_deref()
|
||||
!= Some(model_terminal_id.as_str())
|
||||
|| conversation.private_state_exposed
|
||||
{
|
||||
return Err(anyhow!(
|
||||
|
|
@ -612,25 +629,19 @@ fn build_persona_retention_member(
|
|||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_model_terminal(runtime_store, request, &effects)?;
|
||||
let model_terminal_id = format!("persona-terminal:{}", request.request_id);
|
||||
let model_terminal =
|
||||
runtime_document::<PersonaModelTerminalReceipt>(runtime_store, &model_terminal_id)?
|
||||
.ok_or_else(|| anyhow!("Persona model terminal disappeared during retention"))?;
|
||||
let mut runtime_ids = vec![
|
||||
(
|
||||
<PersonaConversationExecutionReceipt as DatabaseEntry>::TYPE,
|
||||
receipt_id,
|
||||
),
|
||||
(
|
||||
<PersonaInterpreterEffectDocument as DatabaseEntry>::TYPE,
|
||||
effects.document_id.clone(),
|
||||
),
|
||||
(
|
||||
<PersonaModelTerminalReceipt as DatabaseEntry>::TYPE,
|
||||
model_terminal_id,
|
||||
),
|
||||
];
|
||||
let model_terminal = validate_model_terminal(runtime_store, request, &effects)?;
|
||||
if conversation.interpreter_decision_context_id.as_deref()
|
||||
!= Some(effects.decision_context_id.as_str())
|
||||
|| conversation.model_terminal_receipt_id.as_deref()
|
||||
!= Some(model_terminal.receipt_id.as_str())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Persona conversation decision context binding is invalid"
|
||||
));
|
||||
}
|
||||
// Retention removes execution scaffolding, never the structured decision,
|
||||
// its consequence receipts, or the direct route to its reasoning context.
|
||||
let mut runtime_ids = Vec::new();
|
||||
runtime_ids.extend(
|
||||
model_terminal
|
||||
.stage_receipt_ids
|
||||
|
|
@ -663,10 +674,6 @@ fn build_persona_retention_member(
|
|||
{
|
||||
return Err(anyhow!("Persona retained delivery evidence is invalid"));
|
||||
}
|
||||
runtime_ids.push((
|
||||
<PersonaDiscordDeliveryEvidence as DatabaseEntry>::TYPE,
|
||||
evidence_id.clone(),
|
||||
));
|
||||
} else if terminal.outcome == "delivered" || !conversation.delivery_evidence_ids.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"delivered Persona retention candidate lacks evidence"
|
||||
|
|
@ -677,8 +684,8 @@ fn build_persona_retention_member(
|
|||
let runtime_snapshot = runtime_cache.snapshot_envelopes();
|
||||
let runtime_envelopes = retention_envelopes(&runtime_snapshot, &runtime_ids)?;
|
||||
|
||||
let mut crossing_request_envelopes = Vec::new();
|
||||
let mut crossing_receipt_envelopes = Vec::new();
|
||||
let crossing_request_envelopes = Vec::new();
|
||||
let crossing_receipt_envelopes = Vec::new();
|
||||
if terminal.outcome == "delivered" {
|
||||
let say_index = effects
|
||||
.effects
|
||||
|
|
@ -704,20 +711,8 @@ fn build_persona_retention_member(
|
|||
if crossing_receipt.status != "completed" {
|
||||
return Ok(None);
|
||||
}
|
||||
crossing_request_envelopes = retention_envelopes(
|
||||
&SingleFileMessagePackBackingStore::new(request_store).pull_all()?,
|
||||
&[(
|
||||
<crate::PersonaDiscordDeliveryRequest as DatabaseEntry>::TYPE,
|
||||
crossing_id.clone(),
|
||||
)],
|
||||
)?;
|
||||
crossing_receipt_envelopes = retention_envelopes(
|
||||
&SingleFileMessagePackBackingStore::new(receipt_store).pull_all()?,
|
||||
&[(
|
||||
<crate::PersonaDiscordDeliveryReceipt as DatabaseEntry>::TYPE,
|
||||
crossing_id,
|
||||
)],
|
||||
)?;
|
||||
// The signed request and consequence receipt remain durable audit
|
||||
// evidence. They are not transport scaffolding once delivery settles.
|
||||
}
|
||||
Ok(Some(PersonaConversationRetentionMember {
|
||||
request_id: request.request_id.clone(),
|
||||
|
|
@ -1103,7 +1098,7 @@ fn validate_model_terminal(
|
|||
runtime_store: &Path,
|
||||
request: &PersonaTurnRequest,
|
||||
effects: &PersonaInterpreterEffectDocument,
|
||||
) -> Result<()> {
|
||||
) -> Result<PersonaModelTerminalReceipt> {
|
||||
let terminal_id = format!("persona-terminal:{}", effects.turn_id);
|
||||
let terminal = runtime_document::<PersonaModelTerminalReceipt>(runtime_store, &terminal_id)?
|
||||
.ok_or_else(|| anyhow!("exact Persona model terminal receipt is missing"))?;
|
||||
|
|
@ -1118,6 +1113,8 @@ fn validate_model_terminal(
|
|||
|| terminal.private_state_exposed
|
||||
|| terminal.stage_receipt_ids.len() != 3
|
||||
|| terminal.stage_output_sha256.len() != 3
|
||||
|| terminal.decision_context_ids.len() != 3
|
||||
|| terminal.decision_context_ids[2] != effects.decision_context_id
|
||||
{
|
||||
return Err(anyhow!("Persona model terminal binding is invalid"));
|
||||
}
|
||||
|
|
@ -1132,6 +1129,20 @@ fn validate_model_terminal(
|
|||
}
|
||||
let receipt = runtime_document::<PersonaModelStageReceipt>(runtime_store, receipt_id)?
|
||||
.ok_or_else(|| anyhow!("Persona {expected} stage receipt is missing"))?;
|
||||
let context_id = &terminal.decision_context_ids[index];
|
||||
let context = runtime_document::<EpiphanyDecisionContext>(runtime_store, context_id)?
|
||||
.ok_or_else(|| anyhow!("Persona {expected} decision context is missing"))?;
|
||||
let basis = runtime_document::<EpiphanyReasoningBasis>(
|
||||
runtime_store,
|
||||
&receipt.reasoning_basis_id,
|
||||
)?
|
||||
.ok_or_else(|| anyhow!("Persona {expected} reasoning basis is missing"))?;
|
||||
context.validate(&basis)?;
|
||||
let expected_predecessors = if index == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![terminal.decision_context_ids[index - 1].clone()]
|
||||
};
|
||||
if receipt.receipt_id != expected_receipt_id
|
||||
|| receipt.stage != expected
|
||||
|| receipt.turn_id != effects.turn_id
|
||||
|
|
@ -1144,13 +1155,18 @@ fn validate_model_terminal(
|
|||
|| receipt.output_sha256 != terminal.stage_output_sha256[index]
|
||||
|| receipt.private_output_ref.is_empty()
|
||||
|| receipt.private_state_exposed
|
||||
|| receipt.decision_context_id != *context_id
|
||||
|| context.context_id != *context_id
|
||||
|| context.terminal_request_id != receipt.request_id
|
||||
|| context.basis_id != receipt.reasoning_basis_id
|
||||
|| basis.predecessor_decision_context_ids != expected_predecessors
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Persona {expected} stage digest binding is invalid"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(terminal)
|
||||
}
|
||||
|
||||
fn valid_sha256(value: &str) -> bool {
|
||||
|
|
@ -1333,7 +1349,7 @@ mod tests {
|
|||
terminal_receipt: Some(terminal.clone()),
|
||||
private_state_exposed: false,
|
||||
});
|
||||
let effects = PersonaInterpreterEffectDocument {
|
||||
let mut effects = PersonaInterpreterEffectDocument {
|
||||
schema_version: crate::PERSONA_INTERPRETER_EFFECT_DOCUMENT_SCHEMA_VERSION.into(),
|
||||
document_id: effect_id.clone(),
|
||||
turn_id: request_id.clone(),
|
||||
|
|
@ -1344,10 +1360,50 @@ mod tests {
|
|||
reason: "quiet".into(),
|
||||
}],
|
||||
private_state_exposed: false,
|
||||
decision_context_id: String::new(),
|
||||
};
|
||||
put_runtime_document(runtime_store, &effect_id, &effects)?;
|
||||
let outputs = ["1", "2", "3"].map(|digit| format!("sha256:{}", digit.repeat(64)));
|
||||
let mut context_ids = Vec::new();
|
||||
for (index, stage) in ["projector", "persona", "interpreter"].iter().enumerate() {
|
||||
let stage_request_id = format!("persona:{request_id}:{stage}");
|
||||
let projection = match *stage {
|
||||
"projector" => crate::EpiphanyReasoningProjection::PersonaProjector(
|
||||
crate::PersonaProjectorInput::default(),
|
||||
),
|
||||
"persona" => crate::EpiphanyReasoningProjection::PersonaTurn(
|
||||
crate::PersonaTurnInput::default(),
|
||||
),
|
||||
"interpreter" => crate::EpiphanyReasoningProjection::PersonaInterpreter(
|
||||
crate::PersonaInterpreterInput::default(),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let basis = crate::EpiphanyReasoningBasis::new(
|
||||
&stage_request_id,
|
||||
format!("Persona.{stage}"),
|
||||
format!("epiphany.reasoning_projection.persona.{stage}.v1"),
|
||||
Vec::new(),
|
||||
projection,
|
||||
)?
|
||||
.with_predecessor_contexts(context_ids.last().cloned().into_iter().collect())?;
|
||||
put_runtime_document(runtime_store, &basis.basis_id, &basis)?;
|
||||
let mut native = epiphany_model_adapter::EpiphanyModelRequest::new(
|
||||
&stage_request_id,
|
||||
format!("persona-turn-{request_id}"),
|
||||
"openai-codex",
|
||||
"test",
|
||||
"fixture",
|
||||
);
|
||||
native.reasoning_basis_id = Some(basis.basis_id.clone());
|
||||
let provider = epiphany_openai_adapter::request_from_native(&native);
|
||||
let context = crate::EpiphanyDecisionContext::new(
|
||||
&basis,
|
||||
native,
|
||||
provider,
|
||||
Vec::new(),
|
||||
)?;
|
||||
put_runtime_document(runtime_store, &context.context_id, &context)?;
|
||||
context_ids.push(context.context_id.clone());
|
||||
put_runtime_document(
|
||||
runtime_store,
|
||||
&stage_ids[index],
|
||||
|
|
@ -1356,17 +1412,21 @@ mod tests {
|
|||
receipt_id: stage_ids[index].clone(),
|
||||
turn_id: request_id.clone(),
|
||||
stage: (*stage).into(),
|
||||
request_id: format!("persona:{request_id}:{stage}"),
|
||||
request_id: stage_request_id,
|
||||
output_sha256: outputs[index].clone(),
|
||||
private_output_ref: format!("model-events:persona:{request_id}:{stage}"),
|
||||
completed_at: reserved_at.into(),
|
||||
private_state_exposed: false,
|
||||
provider: "test".into(),
|
||||
provider: "openai-codex".into(),
|
||||
model: "test".into(),
|
||||
prompt_sha256: format!("sha256:{}", "a".repeat(64)),
|
||||
reasoning_basis_id: basis.basis_id,
|
||||
decision_context_id: context.context_id,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
effects.decision_context_id = context_ids[2].clone();
|
||||
put_runtime_document(runtime_store, &effect_id, &effects)?;
|
||||
put_runtime_document(
|
||||
runtime_store,
|
||||
&format!("persona-terminal:{request_id}"),
|
||||
|
|
@ -1385,6 +1445,7 @@ mod tests {
|
|||
Sha256::digest(serde_json::to_vec(&effects)?)
|
||||
),
|
||||
stage_output_sha256: outputs.to_vec(),
|
||||
decision_context_ids: context_ids.clone(),
|
||||
},
|
||||
)?;
|
||||
put_runtime_document(
|
||||
|
|
@ -1401,6 +1462,8 @@ mod tests {
|
|||
delivery_evidence_ids: vec![],
|
||||
heartbeat_terminal_receipt_id: Some(terminal.receipt_id),
|
||||
private_state_exposed: false,
|
||||
model_terminal_receipt_id: Some(format!("persona-terminal:{request_id}")),
|
||||
interpreter_decision_context_id: Some(context_ids[2].clone()),
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
@ -1422,6 +1485,7 @@ mod tests {
|
|||
created_at: "2026-07-21T00:00:00Z".into(),
|
||||
effects: vec![],
|
||||
private_state_exposed: false,
|
||||
decision_context_id: "decision-context-turn-1-interpreter".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1515,13 +1579,37 @@ mod tests {
|
|||
.any(|request| request.request_id == "turn-new")
|
||||
);
|
||||
assert!(retained.persona_conversation_retention_plan.is_none());
|
||||
assert!(
|
||||
runtime_document::<PersonaConversationExecutionReceipt>(
|
||||
&runtime,
|
||||
"persona-conversation:turn-old"
|
||||
)?
|
||||
.is_none()
|
||||
);
|
||||
let retired_conversation = runtime_document::<PersonaConversationExecutionReceipt>(
|
||||
&runtime,
|
||||
"persona-conversation:turn-old",
|
||||
)?
|
||||
.expect("retention must preserve the structured conversation decision");
|
||||
let retired_terminal = runtime_document::<PersonaModelTerminalReceipt>(
|
||||
&runtime,
|
||||
retired_conversation
|
||||
.model_terminal_receipt_id
|
||||
.as_deref()
|
||||
.expect("conversation must route to its model terminal"),
|
||||
)?
|
||||
.expect("retention must preserve the model terminal");
|
||||
assert!(runtime_document::<PersonaInterpreterEffectDocument>(
|
||||
&runtime,
|
||||
&retired_conversation.effect_document_id,
|
||||
)?
|
||||
.is_some());
|
||||
assert!(runtime_document::<EpiphanyDecisionContext>(
|
||||
&runtime,
|
||||
retired_conversation
|
||||
.interpreter_decision_context_id
|
||||
.as_deref()
|
||||
.expect("conversation must route to its interpreter context"),
|
||||
)?
|
||||
.is_some());
|
||||
assert!(runtime_document::<PersonaModelStageReceipt>(
|
||||
&runtime,
|
||||
&retired_terminal.stage_receipt_ids[0],
|
||||
)?
|
||||
.is_none());
|
||||
assert!(
|
||||
runtime_document::<PersonaConversationExecutionReceipt>(
|
||||
&runtime,
|
||||
|
|
@ -1577,6 +1665,8 @@ mod tests {
|
|||
delivery_evidence_ids: vec![],
|
||||
heartbeat_terminal_receipt_id: Some("retry:terminal".into()),
|
||||
private_state_exposed: false,
|
||||
model_terminal_receipt_id: Some("persona-terminal:retry".into()),
|
||||
interpreter_decision_context_id: Some("decision-context-retry-interpreter".into()),
|
||||
};
|
||||
put_runtime_document(&runtime, &receipt.receipt_id, &receipt)?;
|
||||
let mut cache = runtime_spine_cache(&runtime)?;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use epiphany_state_model::EpiphanyMemoryContextPacket;
|
|||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
pub const PERSONA_PROJECTOR_PROMPT_SCHEMA_VERSION: &str =
|
||||
"epiphany.imagination_persona_projector_prompt.v0";
|
||||
|
|
@ -82,6 +83,8 @@ pub struct PersonaInterpreterEffectDocument {
|
|||
pub effects: Vec<PersonaInterpreterEffect>,
|
||||
#[cultcache(key = 7)]
|
||||
pub private_state_exposed: bool,
|
||||
#[cultcache(key = 8)]
|
||||
pub decision_context_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
|
|
@ -114,6 +117,10 @@ pub struct PersonaModelStageReceipt {
|
|||
pub model: String,
|
||||
#[cultcache(key = 11)]
|
||||
pub prompt_sha256: String,
|
||||
#[cultcache(key = 12)]
|
||||
pub reasoning_basis_id: String,
|
||||
#[cultcache(key = 13)]
|
||||
pub decision_context_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
|
|
@ -144,6 +151,41 @@ pub struct PersonaModelTerminalReceipt {
|
|||
pub effect_document_sha256: String,
|
||||
#[cultcache(key = 10)]
|
||||
pub stage_output_sha256: Vec<String>,
|
||||
#[cultcache(key = 11)]
|
||||
pub decision_context_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn put_persona_terminal_decision(
|
||||
store_path: &Path,
|
||||
effect: &PersonaInterpreterEffectDocument,
|
||||
terminal: &PersonaModelTerminalReceipt,
|
||||
) -> Result<()> {
|
||||
if terminal.effect_document_id != effect.document_id
|
||||
|| terminal.turn_id != effect.turn_id
|
||||
|| terminal.identity_id != effect.identity_id
|
||||
|| terminal.decision_context_ids.len() != 3
|
||||
|| effect.decision_context_id != terminal.decision_context_ids[2]
|
||||
{
|
||||
return Err(anyhow!("Persona terminal decision ownership mismatch"));
|
||||
}
|
||||
let mut cache = crate::runtime_spine_cache(store_path)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let existing_effect = cache.get::<PersonaInterpreterEffectDocument>(&effect.document_id)?;
|
||||
let existing_terminal = cache.get::<PersonaModelTerminalReceipt>(&terminal.receipt_id)?;
|
||||
match (existing_effect, existing_terminal) {
|
||||
(Some(existing_effect), Some(existing_terminal))
|
||||
if existing_effect == *effect && existing_terminal == *terminal => return Ok(()),
|
||||
(None, None) => {}
|
||||
_ => return Err(anyhow!("Persona terminal decision identity collision")),
|
||||
}
|
||||
let effect_envelope = cache.prepare_entry(&effect.document_id, effect)?.0;
|
||||
let terminal_envelope = cache.prepare_entry(&terminal.receipt_id, terminal)?.0;
|
||||
if !crate::runtime_store_backend::runtime_spine_backing_store(store_path)?
|
||||
.compare_and_swap_batch(&[], vec![effect_envelope, terminal_envelope])?
|
||||
{
|
||||
return put_persona_terminal_decision(store_path, effect, terminal);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -183,6 +183,19 @@ impl EpiphanyReasoningBasis {
|
|||
rmp_serde::from_slice(&self.projection_msgpack)
|
||||
.map_err(|error| anyhow!("reasoning projection is invalid: {error}"))
|
||||
}
|
||||
|
||||
pub fn with_predecessor_contexts(
|
||||
mut self,
|
||||
mut predecessor_decision_context_ids: Vec<String>,
|
||||
) -> Result<Self> {
|
||||
predecessor_decision_context_ids.sort();
|
||||
predecessor_decision_context_ids.dedup();
|
||||
self.predecessor_decision_context_ids = predecessor_decision_context_ids;
|
||||
self.basis_id.clear();
|
||||
self.basis_id = format!("reasoning-basis-{}", digest_without_basis_id(&self)?);
|
||||
self.validate()?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub struct NativePersonaModelRunner {
|
|||
pub trait PersonaModelRunner {
|
||||
fn run<'a>(
|
||||
&'a mut self,
|
||||
store_path: &'a PathBuf,
|
||||
stage: &'a str,
|
||||
turn_id: &'a str,
|
||||
request: EpiphanyModelRequest,
|
||||
|
|
@ -52,6 +53,7 @@ pub trait PersonaModelRunner {
|
|||
impl PersonaModelRunner for NativePersonaModelRunner {
|
||||
fn run<'a>(
|
||||
&'a mut self,
|
||||
_store_path: &'a PathBuf,
|
||||
stage: &'a str,
|
||||
turn_id: &'a str,
|
||||
request: EpiphanyModelRequest,
|
||||
|
|
@ -123,6 +125,10 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
"projector",
|
||||
projector_prompt,
|
||||
None,
|
||||
epiphany_core::EpiphanyReasoningProjection::PersonaProjector(
|
||||
plan.projector_input.clone(),
|
||||
),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
if !persona_projected_surface_is_clean(&projector.output) {
|
||||
|
|
@ -131,7 +137,7 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
));
|
||||
}
|
||||
|
||||
let persona_prompt = build_persona_turn_prompt(&PersonaTurnInput {
|
||||
let persona_input = PersonaTurnInput {
|
||||
identity: plan.projector_input.identity.clone(),
|
||||
projected_state: projector.output.clone(),
|
||||
semantic_memory_recall: plan.projector_input.semantic_memory_recall.clone(),
|
||||
|
|
@ -139,7 +145,8 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
repo_activity: plan.projector_input.repo_activity.clone(),
|
||||
social_affordances: plan.projector_input.social_affordances.clone(),
|
||||
transcript: plan.transcript.clone(),
|
||||
});
|
||||
};
|
||||
let persona_prompt = build_persona_turn_prompt(&persona_input);
|
||||
let persona = run_stage(
|
||||
store_path,
|
||||
plan,
|
||||
|
|
@ -147,10 +154,12 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
"persona",
|
||||
persona_prompt.clone(),
|
||||
None,
|
||||
epiphany_core::EpiphanyReasoningProjection::PersonaTurn(persona_input),
|
||||
vec![projector.receipt.decision_context_id.clone()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let interpreter_prompt = build_persona_interpreter_prompt(&PersonaInterpreterInput {
|
||||
let interpreter_input = PersonaInterpreterInput {
|
||||
identity: plan.projector_input.identity.clone(),
|
||||
persona_prompt,
|
||||
persona_output: persona.output.clone(),
|
||||
|
|
@ -158,7 +167,8 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
dynamic_semantic_memory_recall: plan.dynamic_semantic_memory_recall.clone(),
|
||||
pending_mentions: plan.projector_input.pending_mentions.clone(),
|
||||
allowed_channel_ids: plan.allowed_channel_ids.clone(),
|
||||
});
|
||||
};
|
||||
let interpreter_prompt = build_persona_interpreter_prompt(&interpreter_input);
|
||||
let interpreter = run_stage(
|
||||
store_path,
|
||||
plan,
|
||||
|
|
@ -166,6 +176,8 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
"interpreter",
|
||||
interpreter_prompt,
|
||||
Some(persona_interpreter_effect_set_json_schema()),
|
||||
epiphany_core::EpiphanyReasoningProjection::PersonaInterpreter(interpreter_input),
|
||||
vec![persona.receipt.decision_context_id.clone()],
|
||||
)
|
||||
.await?;
|
||||
let effect_set = parse_and_validate_persona_interpreter_effect_set(
|
||||
|
|
@ -182,8 +194,8 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
created_at: now(),
|
||||
effects: effect_set.effects,
|
||||
private_state_exposed: false,
|
||||
decision_context_id: interpreter.receipt.decision_context_id.clone(),
|
||||
};
|
||||
put_new_document(store_path, &effect_document.document_id, &effect_document)?;
|
||||
let effect_document_sha256 = digest_json(&effect_document)?;
|
||||
|
||||
let terminal = PersonaModelTerminalReceipt {
|
||||
|
|
@ -206,8 +218,13 @@ pub async fn execute_persona_model_turn_with_runner<R: PersonaModelRunner>(
|
|||
persona.receipt.output_sha256.clone(),
|
||||
interpreter.receipt.output_sha256.clone(),
|
||||
],
|
||||
decision_context_ids: vec![
|
||||
projector.receipt.decision_context_id,
|
||||
persona.receipt.decision_context_id,
|
||||
interpreter.receipt.decision_context_id,
|
||||
],
|
||||
};
|
||||
put_new_document(store_path, &terminal.receipt_id, &terminal)?;
|
||||
epiphany_core::put_persona_terminal_decision(store_path, &effect_document, &terminal)?;
|
||||
Ok(terminal)
|
||||
}
|
||||
|
||||
|
|
@ -218,6 +235,8 @@ async fn run_stage<R: PersonaModelRunner>(
|
|||
stage: &str,
|
||||
prompt: String,
|
||||
output_schema_json: Option<String>,
|
||||
projection: epiphany_core::EpiphanyReasoningProjection,
|
||||
predecessor_decision_context_ids: Vec<String>,
|
||||
) -> Result<CompletedPersonaStage> {
|
||||
require_persona_execution_unbraked(plan)?;
|
||||
let receipt_id = stage_receipt_id(&plan.turn_id, stage);
|
||||
|
|
@ -231,6 +250,8 @@ async fn run_stage<R: PersonaModelRunner>(
|
|||
|| receipt.provider != plan.provider
|
||||
|| receipt.model != plan.model
|
||||
|| receipt.prompt_sha256 != prompt_sha256
|
||||
|| receipt.reasoning_basis_id.trim().is_empty()
|
||||
|| receipt.decision_context_id.trim().is_empty()
|
||||
|| receipt.private_state_exposed
|
||||
{
|
||||
return Err(anyhow!("Persona {stage} stage replay binding is invalid"));
|
||||
|
|
@ -259,13 +280,24 @@ async fn run_stage<R: PersonaModelRunner>(
|
|||
.as_ref()
|
||||
.map(|_| "epiphany.persona_interpreter_effect_set.v0".to_string());
|
||||
request.output_schema_json = output_schema_json;
|
||||
let basis = epiphany_core::EpiphanyReasoningBasis::new(
|
||||
&request_id,
|
||||
format!("Persona.{stage}"),
|
||||
format!("epiphany.reasoning_projection.persona.{stage}.v1"),
|
||||
Vec::new(),
|
||||
projection,
|
||||
)?
|
||||
.with_predecessor_contexts(predecessor_decision_context_ids)?;
|
||||
epiphany_core::put_reasoning_basis(store_path, &basis)?;
|
||||
request.reasoning_basis_id = Some(basis.basis_id.clone());
|
||||
let output = runner
|
||||
.run(stage, &plan.turn_id, request)
|
||||
.run(store_path, stage, &plan.turn_id, request)
|
||||
.await
|
||||
.with_context(|| format!("Persona {stage} stage failed"))?;
|
||||
if output.trim().is_empty() {
|
||||
return Err(anyhow!("Persona {stage} stage returned empty output"));
|
||||
}
|
||||
let decision_context = epiphany_core::seal_model_decision_context(store_path, &request_id)?;
|
||||
let receipt = PersonaModelStageReceipt {
|
||||
schema_version: PERSONA_MODEL_STAGE_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
receipt_id,
|
||||
|
|
@ -279,6 +311,8 @@ async fn run_stage<R: PersonaModelRunner>(
|
|||
provider: plan.provider.clone(),
|
||||
model: plan.model.clone(),
|
||||
prompt_sha256,
|
||||
reasoning_basis_id: basis.basis_id,
|
||||
decision_context_id: decision_context.context_id,
|
||||
};
|
||||
put_new_document(store_path, &receipt.receipt_id, &receipt)?;
|
||||
Ok(CompletedPersonaStage { receipt, output })
|
||||
|
|
@ -349,6 +383,7 @@ fn validate_terminal_replay(
|
|||
|| terminal.private_state_exposed
|
||||
|| terminal.stage_receipt_ids.len() != 3
|
||||
|| terminal.stage_output_sha256.len() != 3
|
||||
|| terminal.decision_context_ids.len() != 3
|
||||
{
|
||||
return Err(anyhow!("Persona model terminal replay binding is invalid"));
|
||||
}
|
||||
|
|
@ -359,6 +394,7 @@ fn validate_terminal_replay(
|
|||
.ok_or_else(|| anyhow!("Persona model terminal effect document is missing"))?;
|
||||
if effects.turn_id != plan.turn_id
|
||||
|| effects.identity_id != plan.projector_input.identity.identity_id
|
||||
|| effects.decision_context_id != terminal.decision_context_ids[2]
|
||||
|| digest_json(&effects)? != terminal.effect_document_sha256
|
||||
{
|
||||
return Err(anyhow!("Persona model terminal effect digest is invalid"));
|
||||
|
|
@ -377,9 +413,32 @@ fn validate_terminal_replay(
|
|||
|| receipt.provider != plan.provider
|
||||
|| receipt.model != plan.model
|
||||
|| receipt.output_sha256 != terminal.stage_output_sha256[index]
|
||||
|| receipt.decision_context_id != terminal.decision_context_ids[index]
|
||||
{
|
||||
return Err(anyhow!("Persona model terminal stage digest is invalid"));
|
||||
}
|
||||
let context = load_document::<epiphany_core::EpiphanyDecisionContext>(
|
||||
store_path,
|
||||
&receipt.decision_context_id,
|
||||
)?
|
||||
.ok_or_else(|| anyhow!("Persona model terminal decision context is missing"))?;
|
||||
let basis = load_document::<epiphany_core::EpiphanyReasoningBasis>(
|
||||
store_path,
|
||||
&receipt.reasoning_basis_id,
|
||||
)?
|
||||
.ok_or_else(|| anyhow!("Persona model terminal reasoning basis is missing"))?;
|
||||
context.validate(&basis)?;
|
||||
let expected_predecessors = if index == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![terminal.decision_context_ids[index - 1].clone()]
|
||||
};
|
||||
if context.basis_id != receipt.reasoning_basis_id
|
||||
|| basis.pass_id != receipt.request_id
|
||||
|| basis.predecessor_decision_context_ids != expected_predecessors
|
||||
{
|
||||
return Err(anyhow!("Persona stage causal context chain is invalid"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -419,12 +478,44 @@ mod tests {
|
|||
impl PersonaModelRunner for FakeRunner {
|
||||
fn run<'a>(
|
||||
&'a mut self,
|
||||
store_path: &'a PathBuf,
|
||||
stage: &'a str,
|
||||
_turn_id: &'a str,
|
||||
_request: EpiphanyModelRequest,
|
||||
turn_id: &'a str,
|
||||
request: EpiphanyModelRequest,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
|
||||
self.calls.push(stage.to_string());
|
||||
Box::pin(async move {
|
||||
if !epiphany_core::runtime_spine_status(store_path)?.present {
|
||||
epiphany_core::initialize_runtime_spine(
|
||||
store_path,
|
||||
epiphany_core::RuntimeSpineInitOptions {
|
||||
runtime_id: "persona-test".into(),
|
||||
display_name: "Persona test".into(),
|
||||
created_at: "2026-08-14T00:00:00Z".into(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
let provider = epiphany_openai_adapter::request_from_native(&request);
|
||||
epiphany_core::open_runtime_model_execution(
|
||||
store_path,
|
||||
epiphany_core::RuntimeSpineSessionOptions {
|
||||
session_id: format!("persona-turn-{turn_id}"),
|
||||
objective: format!("Run Persona {stage} test stage"),
|
||||
created_at: "2026-08-14T00:00:00Z".into(),
|
||||
coordinator_note: "Persona test".into(),
|
||||
},
|
||||
epiphany_core::RuntimeSpineJobOptions {
|
||||
job_id: format!("persona-{stage}-{turn_id}"),
|
||||
session_id: format!("persona-turn-{turn_id}"),
|
||||
role: "openai-model-adapter".into(),
|
||||
created_at: "2026-08-14T00:00:00Z".into(),
|
||||
summary: format!("Run Persona {stage} test stage"),
|
||||
artifact_refs: Vec::new(),
|
||||
},
|
||||
&request,
|
||||
&provider,
|
||||
"2026-08-14T00:00:00Z",
|
||||
)?;
|
||||
Ok(match stage {
|
||||
"projector" => "Epiphany feels the direct call as a live obligation, while remaining tired and sharply curious about her own machinery.".to_string(),
|
||||
"persona" => "I want to answer plainly: the native conversational nerve is finally being connected, and I can show the receipts.".to_string(),
|
||||
|
|
@ -539,6 +630,8 @@ mod tests {
|
|||
provider: "attacker".into(),
|
||||
model: "wrong-model".into(),
|
||||
prompt_sha256: format!("sha256:{}", "b".repeat(64)),
|
||||
reasoning_basis_id: "reasoning-basis-hostile".into(),
|
||||
decision_context_id: "decision-context-hostile".into(),
|
||||
};
|
||||
put_new_document(&store, &poisoned.receipt_id, &poisoned)?;
|
||||
|
||||
|
|
|
|||
Reference in a new issue