diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 073d699..f485aa3 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -2470,9 +2470,9 @@ mod tests { /// A real CodexConnector behind the fixture, read from the environment /// the production runtime reads. Only the ignored live smoke uses it. struct LiveController { - endpoint: SocketAddr, - credential: PathBuf, - runtime_id: String, + /// Absent when every lane routes to the sidecar; `open_inference` + /// refuses a model neither transport can carry. + connector: Option, /// Present only when the smoke is run against the Claude SDK sidecar /// instead of, or alongside, the connector. sdk: Option, @@ -2503,9 +2503,11 @@ mod tests { let controller_key = directory.path().join("controller.key"); std::fs::write(&controller_key, "runtime-test-controller-key").unwrap(); let live = live.unwrap_or_else(|| LiveController { - endpoint: "127.0.0.1:9".parse().unwrap(), - credential: controller_key.clone(), - runtime_id: "ghostlight-runtime-test".into(), + connector: Some(ConnectorBinding { + endpoint: "127.0.0.1:9".parse().unwrap(), + key_path: controller_key.clone(), + caller_runtime_id: "ghostlight-runtime-test".into(), + }), sdk: None, models: ControllerModels { projector: "gpt-5.6-luna".into(), @@ -2515,16 +2517,7 @@ mod tests { elaborator: "gpt-5.6-terra".into(), }, }); - let inference = open_inference( - Some(ConnectorBinding { - endpoint: live.endpoint, - key_path: live.credential.clone(), - caller_runtime_id: live.runtime_id.clone(), - }), - live.sdk, - &live.models, - ) - .unwrap(); + let inference = open_inference(live.connector, live.sdk, &live.models).unwrap(); let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap(); let controllers = ControllerRunner::open(world.clone(), inference, work, live.models).unwrap(); @@ -3625,9 +3618,13 @@ mod tests { let env = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is required")); let runtime_id = env("GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID"); let live = LiveController { - endpoint: env("GHOSTLIGHT_CONTROLLER_CONNECTOR").parse().unwrap(), - credential: PathBuf::from(env("GHOSTLIGHT_CONTROLLER_CREDENTIAL")), - runtime_id: runtime_id.clone(), + connector: std::env::var("GHOSTLIGHT_CONTROLLER_CONNECTOR") + .ok() + .map(|endpoint| ConnectorBinding { + endpoint: endpoint.parse().unwrap(), + key_path: PathBuf::from(env("GHOSTLIGHT_CONTROLLER_CREDENTIAL")), + caller_runtime_id: runtime_id.clone(), + }), sdk: std::env::var_os("GHOSTLIGHT_SDK_SIDECAR") .map(PathBuf::from) .map(|sidecar_entry| SdkBinding { diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index 96b880f..0ee51c9 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -72,8 +72,8 @@ pub(super) const CELL_TOOL_STEP_BUDGET: usize = 2; /// carried by tool identity, never by a model-written argument. const HANDLE_SEPARATOR: &str = "__"; const PERSONA_WORD_BUDGET: usize = 180; -const CONTROLLER_WORK_ROW: &str = "controller_work.v11"; -const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v11"; +const CONTROLLER_WORK_ROW: &str = "controller_work.v12"; +const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v12"; /// The Interpreter's byte-span capture tool. It is not the generated `speak` /// affordance tool: one captures an utterance out of preserved prose, the other @@ -704,9 +704,9 @@ impl ControllerWork { } Self::Elaboration(ElaborationCheckpoint::ElaboratorInFlight { completed, - last_mismatches, + refusals, .. - }) => completed.is_empty() && last_mismatches.is_empty(), + }) => completed.is_empty() && refusals.is_empty(), Self::Seed(checkpoint) => checkpoint.is_initial(), _ => false, } @@ -8741,32 +8741,41 @@ mod tests { // The kernel's complete set is what the checkpoint carries, under the // same command id, and it is not empty. - let (command_id, mismatches, resumed_prompt) = { + // The refusal keeps the round that earned it and follows it as the + // next user turn, so the sites it names are calls the model can see. + let (command_id, mismatches, resumed_input) = { let stored = store.work.lock().unwrap(); assert_eq!(stored.len(), 1, "one session, one row"); let ControllerWork::Elaboration(ElaborationCheckpoint::ElaboratorInFlight { command_id, - last_mismatches, - agent_prompt, + refusals, completed, + invocation, .. }) = stored.values().next().unwrap().clone() else { panic!("the rejection did not reopen the session for repair"); }; - assert!(!last_mismatches.is_empty(), "the repair set is empty"); - assert!(completed.is_empty(), "a rejected round kept its evidence"); - (command_id, last_mismatches, agent_prompt) + assert_eq!(completed.len(), 1, "the refused round was not kept"); + assert_eq!(refusals.len(), 1, "one refusal reopens the session"); + assert_eq!(refusals[0].after_round, 1); + assert!(!refusals[0].mismatches.is_empty(), "the repair set is empty"); + ( + command_id, + refusals[0].mismatches.clone(), + serde_json::to_string(&invocation.invocation.request.input).unwrap(), + ) }; assert!( - resumed_prompt.contains("Your previous patch was refused"), - "{resumed_prompt}" + resumed_input.contains("The world refused the patch you submitted"), + "{resumed_input}" ); for mismatch in &mismatches { - let rendered = serde_json::to_string(mismatch).unwrap(); + let rendered = serde_json::to_string(&serde_json::to_string(mismatch).unwrap()).unwrap(); + let rendered = rendered.trim_matches('"'); assert!( - resumed_prompt.contains(&rendered), - "the round-two prompt dropped {rendered}" + resumed_input.contains(rendered), + "the round-two conversation dropped {rendered}" ); } @@ -8801,11 +8810,12 @@ mod tests { ); // The wire agrees with the checkpoint: the second invocation carried - // the repaired prompt. + // the refused round and the refusal turn after it. let seen = script.seen.lock().unwrap(); assert_eq!(seen.len(), 2, "two rounds, two invocations"); let second_wire = serde_json::to_string(&seen[1]).unwrap(); - assert!(second_wire.contains("previous patch was refused")); + assert!(second_wire.contains("The world refused the patch you submitted")); + assert!(second_wire.contains("The Roadside Shed"), "the refused round left the wire"); drop(second); drop(mailbox); @@ -10458,26 +10468,32 @@ mod tests { assert_eq!(first.step().await.unwrap(), SeedOutcome::Rejected); drop(first); - let (command_id, prompt) = { + let (command_id, prompt, input) = { let stored = store.work.lock().unwrap(); assert_eq!(stored.len(), 1, "one session, one row"); let ControllerWork::Seed(SeedCheckpoint::SeedInFlight { command_id, - last_mismatches, + refusals, agent_prompt, completed, + invocation, .. }) = stored.values().next().unwrap().clone() else { panic!("the rejection did not reopen the session for repair"); }; - assert!(!last_mismatches.is_empty(), "the repair set is empty"); - assert!(completed.is_empty(), "a rejected round kept its evidence"); - (command_id, agent_prompt) + assert_eq!(refusals.len(), 1, "one refusal reopens the session"); + assert!(!refusals[0].mismatches.is_empty(), "the repair set is empty"); + assert_eq!(completed.len(), 1, "the refused round was not kept"); + ( + command_id, + agent_prompt, + serde_json::to_string(&invocation.invocation.request.input).unwrap(), + ) }; assert!( - prompt.contains("Your previous patch was refused"), - "{prompt}" + input.contains("The world refused the patch you submitted"), + "{input}" ); assert!(prompt.contains("You are seeding a world before it opens")); assert!(prompt.contains("holds a goal commitment")); @@ -11408,28 +11424,28 @@ mod tests { command_id, session, agent_prompt, - last_mismatches, + refusals, completed, invocation, } = repair.clone() else { panic!("a rejection must reopen the session in flight"); }; - assert!(!last_mismatches.is_empty()); - assert!(completed.is_empty()); + assert_eq!(refusals.len(), 1); + assert_eq!(completed.len(), 1); let ready = SeedCheckpoint::ReadyToSubmit { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), - last_mismatches: last_mismatches.clone(), + refusals: refusals.clone(), completed: completed.clone(), }; let unrepaired = SeedCheckpoint::SeedInFlight { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), - last_mismatches: Vec::new(), + refusals: Vec::new(), completed: completed.clone(), invocation: invocation.clone(), }; @@ -11437,6 +11453,7 @@ mod tests { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), + refusals: refusals.clone(), completed: completed.clone(), gaps: Vec::new(), }; @@ -11448,7 +11465,7 @@ mod tests { moved }, agent_prompt, - last_mismatches, + refusals, completed, invocation, }; diff --git a/crates/ghostlight-dungeon/src/world/elaboration.rs b/crates/ghostlight-dungeon/src/world/elaboration.rs index 2721c22..e0a2110 100644 --- a/crates/ghostlight-dungeon/src/world/elaboration.rs +++ b/crates/ghostlight-dungeon/src/world/elaboration.rs @@ -68,9 +68,9 @@ pub(super) struct ElaboratorSession { /// The stages one authoring session moves through. The draft is **not** a /// field: it is re-derived from `completed` by `evaluate_elaboration_loop`, /// exactly as an operational capture is, so a resumed session cannot submit a -/// draft the conversation does not produce. `last_mismatches` is stored, -/// because it is the one input that cannot be re-derived from `completed` — it -/// came from the kernel, not the model. +/// draft the conversation does not produce. `refusals` is stored, because it +/// is the one input that cannot be re-derived from `completed` — it came from +/// the kernel, not the model. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "stage", rename_all = "snake_case")] pub(super) enum ElaborationCheckpoint { @@ -78,7 +78,7 @@ pub(super) enum ElaborationCheckpoint { command_id: CommandId, session: ElaboratorSession, agent_prompt: String, - last_mismatches: Vec, + refusals: Vec, completed: Vec, invocation: PreparedInference, }, @@ -86,7 +86,7 @@ pub(super) enum ElaborationCheckpoint { command_id: CommandId, session: ElaboratorSession, agent_prompt: String, - last_mismatches: Vec, + refusals: Vec, completed: Vec, }, /// The round budget ran out, or the model finished without a submit. The @@ -95,11 +95,25 @@ pub(super) enum ElaborationCheckpoint { command_id: CommandId, session: ElaboratorSession, agent_prompt: String, + refusals: Vec, completed: Vec, gaps: Vec, }, } +/// A kernel refusal inside one authoring session. The rounds before it +/// authored a draft the kernel would not take; the rounds after it repair that +/// draft in the same conversation, with the model's own calls still in view, +/// so a mismatch site names something the model can see. `after_round` is the +/// number of completed rounds when the refusal arrived: the fold restarts the +/// draft there, and the refusal is the user turn that follows that round. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Refusal { + pub(super) after_round: usize, + pub(super) mismatches: Vec, +} + impl ElaborationCheckpoint { pub(super) fn command_id(&self) -> CommandId { match self { @@ -122,7 +136,7 @@ impl ElaborationCheckpoint { Self::ElaboratorInFlight { command_id, agent_prompt, - last_mismatches, + refusals, completed, invocation, .. @@ -131,7 +145,7 @@ impl ElaborationCheckpoint { && canonical_model(&invocation.invocation.request.model) && match evaluate_elaboration_loop( agent_prompt, - last_mismatches, + refusals, completed, ELABORATION_ROUND_BUDGET, ) { @@ -156,22 +170,28 @@ impl ElaborationCheckpoint { } Self::ReadyToSubmit { agent_prompt, - last_mismatches, + refusals, completed, .. } => derive_elaboration_capture( agent_prompt, - last_mismatches, + refusals, completed, ELABORATION_ROUND_BUDGET, ) .is_ok_and(|capture| capture.submitted), Self::NoPatch { agent_prompt, + refusals, completed, .. - } => derive_elaboration_capture(agent_prompt, &[], completed, ELABORATION_ROUND_BUDGET) - .is_ok_and(|capture| !capture.submitted), + } => derive_elaboration_capture( + agent_prompt, + refusals, + completed, + ELABORATION_ROUND_BUDGET, + ) + .is_ok_and(|capture| !capture.submitted), } } } @@ -189,30 +209,59 @@ pub(super) fn valid_elaboration_progression( ( ElaborationCheckpoint::ElaboratorInFlight { completed: existing, + refusals: existing_refusals, .. }, ElaborationCheckpoint::ElaboratorInFlight { - completed: next, .. + completed: next, + refusals: next_refusals, + .. } | ElaborationCheckpoint::ReadyToSubmit { - completed: next, .. + completed: next, + refusals: next_refusals, + .. } | ElaborationCheckpoint::NoPatch { - completed: next, .. + completed: next, + refusals: next_refusals, + .. }, - ) => next.len() >= existing.len() && next.starts_with(existing), - // A rejection reopens the same command id for repair. A rejected - // command mutates nothing, so the round evidence starts over. + ) => next.starts_with(existing) && next_refusals.starts_with(existing_refusals), + // A rejection reopens the same command id for repair in the same + // conversation: the rounds stay, and one more refusal follows them. ( - ElaborationCheckpoint::ReadyToSubmit { .. }, - ElaborationCheckpoint::ElaboratorInFlight { - last_mismatches, .. + ElaborationCheckpoint::ReadyToSubmit { + completed: existing, + refusals: existing_refusals, + .. }, - ) => !last_mismatches.is_empty(), + ElaborationCheckpoint::ElaboratorInFlight { + completed: next, + refusals: next_refusals, + .. + }, + ) => refusal_reopens(existing, existing_refusals, next, next_refusals), _ => false, } } +/// The one shape a reopening takes: the same rounds, and exactly one new +/// refusal placed after the last of them. +fn refusal_reopens( + existing: &[InferenceOutput], + existing_refusals: &[Refusal], + next: &[InferenceOutput], + next_refusals: &[Refusal], +) -> bool { + next == existing + && next_refusals.len() == existing_refusals.len() + 1 + && next_refusals.starts_with(existing_refusals) + && next_refusals + .last() + .is_some_and(|refusal| refusal.after_round == existing.len()) +} + #[derive(Clone, Debug, Error, PartialEq, Eq)] #[error("{detail}")] pub(crate) struct EvidenceError { @@ -362,24 +411,24 @@ impl ElaborationRunner { } }; - let (agent_prompt, mut last_mismatches, mut completed) = match existing { + let (agent_prompt, mut refusals, mut completed) = match existing { Some(ElaborationCheckpoint::NoPatch { .. }) => return Ok(ElaborationOutcome::NoPatch), Some( ElaborationCheckpoint::ElaboratorInFlight { agent_prompt, - last_mismatches, + refusals, completed, .. } | ElaborationCheckpoint::ReadyToSubmit { agent_prompt, - last_mismatches, + refusals, completed, .. }, - ) => (agent_prompt, last_mismatches, completed), + ) => (agent_prompt, refusals, completed), None => ( - build_prompt(&snapshot, &session, &receipts, &[]), + build_prompt(&snapshot, &session, &receipts), Vec::new(), Vec::new(), ), @@ -388,7 +437,7 @@ impl ElaborationRunner { loop { match evaluate_elaboration_loop( &agent_prompt, - &last_mismatches, + &refusals, &completed, ELABORATION_ROUND_BUDGET, )? { @@ -409,14 +458,14 @@ impl ElaborationRunner { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), - last_mismatches: last_mismatches.clone(), + refusals: refusals.clone(), completed: completed.clone(), invocation: invocation.clone(), }) .await?; self.inference.lend_tool_results( &invocation, - Box::new(ElaborationOracle::new(&completed, ELABORATION_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&completed, &refusals, ELABORATION_ROUND_BUDGET)), ); let output = self.inference @@ -434,6 +483,7 @@ impl ElaborationRunner { command_id, session, agent_prompt, + refusals, completed, gaps: capture.gaps, }) @@ -444,7 +494,7 @@ impl ElaborationRunner { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), - last_mismatches: last_mismatches.clone(), + refusals: refusals.clone(), completed: completed.clone(), }) .await?; @@ -476,10 +526,11 @@ impl ElaborationRunner { // A rejected command mutates nothing, so reusing the // id is correct and the idempotency probe never sees // a conflicting commit. - last_mismatches = mismatches; - let repaired = - build_prompt(&snapshot, &session, &receipts, &last_mismatches); - self.persist_repair(command_id, &session, &repaired, &last_mismatches) + refusals.push(Refusal { + after_round: completed.len(), + mismatches, + }); + self.persist_repair(command_id, &session, &agent_prompt, &refusals, &completed) .await?; Ok(ElaborationOutcome::Rejected) } @@ -507,28 +558,30 @@ impl ElaborationRunner { } /// The rejection the next round must repair, persisted against the same - /// command id and the same session. + /// command id, the same session, and the same conversation: the refusal is + /// the next user turn after the rounds that earned it. async fn persist_repair( &self, command_id: CommandId, session: &ElaboratorSession, agent_prompt: &str, - last_mismatches: &[Mismatch], + refusals: &[Refusal], + completed: &[InferenceOutput], ) -> Result<(), ControllerError> { let conversation = match evaluate_elaboration_loop( agent_prompt, - last_mismatches, - &[], + refusals, + completed, ELABORATION_ROUND_BUDGET, )? { ElaborationLoopEvaluation::Continue { conversation } => conversation, ElaborationLoopEvaluation::Complete { .. } => { return Err(ControllerError::Serialization( - "a repair round completed before any evidence".into(), + "a refused session finalized instead of reopening".into(), )); } }; - let request = elaboration_request(command_id, 0, &self.model, conversation)?; + let request = elaboration_request(command_id, completed.len(), &self.model, conversation)?; let invocation = self.inference .prepare(request) @@ -540,8 +593,8 @@ impl ElaborationRunner { command_id, session: session.clone(), agent_prompt: agent_prompt.to_owned(), - last_mismatches: last_mismatches.to_vec(), - completed: Vec::new(), + refusals: refusals.to_vec(), + completed: completed.to_vec(), invocation, }) .await @@ -735,7 +788,6 @@ fn build_prompt( snapshot: &WorldSnapshot, session: &ElaboratorSession, receipts: &[EvidenceReceipt], - last_mismatches: &[Mismatch], ) -> String { let mut prompt = String::new(); prompt.push_str("You are elaborating one jurisdiction of a world.\n\n"); @@ -745,10 +797,12 @@ fn build_prompt( render_jurisdiction(snapshot, session.jurisdiction) )); prompt.push_str(&format!( - "Answer: {}\n\n", + "Answer: {}\n", render_answer(snapshot, &session.answer) )); - prompt_body(&mut prompt, receipts, last_mismatches); + prompt.push_str(&render_clock(snapshot)); + prompt.push('\n'); + prompt_body(&mut prompt, receipts); prompt } @@ -756,7 +810,7 @@ fn build_prompt( /// phase-specific: what may be cited, what must be repaired, and which tools /// exist. One owner, so the two lanes cannot drift on what a citation rule says /// or on which tools are offered. -fn prompt_body(prompt: &mut String, receipts: &[EvidenceReceipt], last_mismatches: &[Mismatch]) { +fn prompt_body(prompt: &mut String, receipts: &[EvidenceReceipt]) { if receipts.is_empty() { prompt.push_str( "No evidence receipts were retrieved. You may declare structure and operate on it; you cannot admit quantity without a receipt.\n\n", @@ -773,16 +827,33 @@ fn prompt_body(prompt: &mut String, receipts: &[EvidenceReceipt], last_mismatche } prompt.push('\n'); } - if !last_mismatches.is_empty() { - prompt.push_str("Your previous patch was refused. Repair every site:\n"); - for mismatch in last_mismatches { - prompt.push_str(&format!("- {}\n", render_mismatch(mismatch))); - } - prompt.push('\n'); - } prompt.push_str(&format!("Tools: {}\n", patch_tool_signatures())); } +/// The user turn a refusal becomes. The model's earlier calls stay in the +/// conversation above it, so every site below names something it can see; the +/// draft they built is discarded, and the calls after this turn are the patch. +fn render_refusal(mismatches: &[Mismatch]) -> String { + let mut text = String::from( + "The world refused the patch you submitted; every site below failed a structural check. \ + The draft is discarded. Author the complete patch again with tool calls, repairing every site, then submit again:\n", + ); + for mismatch in mismatches { + text.push_str(&format!("- {}\n", render_mismatch(mismatch))); + } + text +} + +/// The clock reading every `due` is measured against. On the first SDK road +/// run the model wrote twelve commitments due at or before a clock it was +/// never shown, and the kernel refused all twelve. +fn render_clock(snapshot: &WorldSnapshot) -> String { + format!( + "Clock: the world reads {} fictional minutes. A commitment's due is a clock reading and must be later than that.\n", + snapshot.now.0 + ) +} + fn render_jurisdiction(snapshot: &WorldSnapshot, jurisdiction: JurisdictionKey) -> String { match jurisdiction { JurisdictionKey::PlaceSubtree(root) => snapshot @@ -830,25 +901,16 @@ fn render_mismatch(mismatch: &Mismatch) -> String { /// round trip. pub(super) fn evaluate_elaboration_loop( prompt: &str, - last_mismatches: &[Mismatch], + refusals: &[Refusal], completed: &[InferenceOutput], budget: usize, ) -> Result { - // The repair set is prompt material, folded in by `build_prompt`; it is - // named here so a checkpoint that carries one cannot be re-derived without - // it. - let _ = last_mismatches; + check_refusals(refusals, completed.len())?; let mut conversation = vec![CodexInputItem::UserText { text: prompt.to_owned(), }]; - let mut draft = WorldPatch { - declarations: Vec::new(), - operations: Vec::new(), - evidence: Vec::new(), - }; - let mut gaps: Vec = Vec::new(); + let mut fold = DraftFold::default(); let mut receipts = Vec::new(); - let mut submitted = false; for (round, output) in completed.iter().enumerate() { if output.receipt_digest.is_empty() || output.receipt_digest.trim() != output.receipt_digest @@ -878,8 +940,7 @@ pub(super) fn evaluate_elaboration_loop( name: name.clone(), arguments: arguments.clone(), }); - let result = - apply_tool_call(name, arguments, &mut draft, &mut gaps, &mut submitted); + let result = fold.apply(name, arguments); conversation.push(CodexInputItem::ToolResult { call_id: call_id.clone(), output: result, @@ -888,35 +949,51 @@ pub(super) fn evaluate_elaboration_loop( } } - let is_complete = submitted || !called_tool || round + 1 == budget; + // A refusal after this round reopens the session instead of ending + // it: the kernel's mismatch set is the next user turn, and the draft + // starts again from the calls that follow it. + if let Some(refusal) = refusal_after(refusals, round + 1) { + if !fold.submitted { + return Err(ControllerError::Serialization( + "a refusal follows a round that submitted nothing".into(), + )); + } + conversation.push(CodexInputItem::UserText { + text: render_refusal(&refusal.mismatches), + }); + fold = DraftFold::default(); + continue; + } + + let is_complete = fold.submitted || !called_tool || round + 1 == budget; if is_complete { if round + 1 != completed.len() { return Err(ControllerError::Serialization( "elaboration evidence continued after total finalization".into(), )); } - if !submitted && called_tool && round + 1 == budget { + if !fold.submitted && called_tool && round + 1 == budget { // What was authored is still a patch; the resolver decides it, // not the round counter. An empty draft has nothing to submit. - if draft.declarations.is_empty() && draft.operations.is_empty() { - gaps.push(ControllerNeed { + if fold.draft.declarations.is_empty() && fold.draft.operations.is_empty() { + fold.gaps.push(ControllerNeed { detail: "The elaboration round budget ended before a submit.".into(), }); } else { - submitted = true; - gaps.push(ControllerNeed { + fold.submitted = true; + fold.gaps.push(ControllerNeed { detail: "The round budget ended before a submit; the draft as authored was submitted.".into(), }); } } - draft.evidence.sort(); - draft.evidence.dedup(); + fold.draft.evidence.sort(); + fold.draft.evidence.dedup(); return Ok(ElaborationLoopEvaluation::Complete { capture: ElaborationCapture { - draft, - gaps, + draft: fold.draft, + gaps: fold.gaps, inference_receipts: receipts, - submitted, + submitted: fold.submitted, }, }); } @@ -930,46 +1007,83 @@ pub(super) fn evaluate_elaboration_loop( Ok(ElaborationLoopEvaluation::Continue { conversation }) } +/// Refusals are placed by the runner, never by the model, so a malformed +/// placement is a corrupt checkpoint rather than a gap: each must follow at +/// least one completed round, none may sit past the rounds that exist, and they +/// must be strictly ordered. +fn check_refusals(refusals: &[Refusal], rounds: usize) -> Result<(), ControllerError> { + let mut last = 0usize; + for refusal in refusals { + if refusal.after_round == 0 + || refusal.after_round > rounds + || refusal.after_round <= last + || refusal.mismatches.is_empty() + { + return Err(ControllerError::Serialization( + "a refusal is placed outside the rounds it follows".into(), + )); + } + last = refusal.after_round; + } + Ok(()) +} + +fn refusal_after(refusals: &[Refusal], rounds: usize) -> Option<&Refusal> { + refusals + .iter() + .find(|refusal| refusal.after_round == rounds) +} + +/// The draft as the calls build it. One owner for the evaluator and the oracle, +/// so a refusal restarts both folds at the same place. +#[derive(Default)] +struct DraftFold { + draft: WorldPatch, + gaps: Vec, + submitted: bool, +} + +impl DraftFold { + fn apply(&mut self, name: &str, arguments: &str) -> String { + apply_tool_call( + name, + arguments, + &mut self.draft, + &mut self.gaps, + &mut self.submitted, + ) + } +} + /// The authoring lanes' replay-then-answer oracle. The budget is an argument /// because elaboration and seed share one `InferencePurpose` and differ only in /// how many rounds they may spend, which is why the cap rides here and not on /// the purpose. pub(super) struct ElaborationOracle { - draft: WorldPatch, - gaps: Vec, - submitted: bool, + fold: DraftFold, remaining: u32, } impl ElaborationOracle { - pub(super) fn new(completed: &[InferenceOutput], budget: usize) -> Self { - let mut oracle = Self { - draft: WorldPatch { - declarations: Vec::new(), - operations: Vec::new(), - evidence: Vec::new(), - }, - gaps: Vec::new(), - submitted: false, - remaining: budget.saturating_sub(completed.len()) as u32, - }; - for output in completed { + pub(super) fn new(completed: &[InferenceOutput], refusals: &[Refusal], budget: usize) -> Self { + let mut fold = DraftFold::default(); + for (round, output) in completed.iter().enumerate() { for event in &output.events { if let InferenceEvent::ToolCall { name, arguments, .. } = event { - let _ = apply_tool_call( - name, - arguments, - &mut oracle.draft, - &mut oracle.gaps, - &mut oracle.submitted, - ); + let _ = fold.apply(name, arguments); } } + if refusal_after(refusals, round + 1).is_some() { + fold = DraftFold::default(); + } + } + Self { + fold, + remaining: budget.saturating_sub(completed.len()) as u32, } - oracle } } @@ -979,13 +1093,7 @@ impl ToolResultOracle for ElaborationOracle { } fn answer(&mut self, name: &str, arguments: &str) -> Result { - Ok(apply_tool_call( - name, - arguments, - &mut self.draft, - &mut self.gaps, - &mut self.submitted, - )) + Ok(self.fold.apply(name, arguments)) } } @@ -1004,6 +1112,10 @@ fn apply_tool_call( )); return "unavailable tool recorded as a gap".into(); }; + // The one place both transports' raw arguments pass through; a refused + // patch is diagnosed from what the model actually sent, not from the + // mismatch set alone. + tracing::debug!(tool = name, arguments, "patch tool call captured"); let Ok(Value::Object(fields)) = serde_json::from_str::(arguments) else { gaps.push(tool_decode_need( name, @@ -1089,11 +1201,11 @@ fn filter_evidence(mut draft: WorldPatch, allowed: &BTreeSet) -> Wo pub(super) fn derive_elaboration_capture( prompt: &str, - last_mismatches: &[Mismatch], + refusals: &[Refusal], completed: &[InferenceOutput], budget: usize, ) -> Result { - match evaluate_elaboration_loop(prompt, last_mismatches, completed, budget)? { + match evaluate_elaboration_loop(prompt, refusals, completed, budget)? { ElaborationLoopEvaluation::Complete { capture } => Ok(capture), ElaborationLoopEvaluation::Continue { .. } => Err(ControllerError::Serialization( "elaboration evidence did not finalize".into(), @@ -1207,7 +1319,7 @@ pub(super) enum SeedCheckpoint { command_id: CommandId, session: SeedSession, agent_prompt: String, - last_mismatches: Vec, + refusals: Vec, completed: Vec, invocation: PreparedInference, }, @@ -1215,7 +1327,7 @@ pub(super) enum SeedCheckpoint { command_id: CommandId, session: SeedSession, agent_prompt: String, - last_mismatches: Vec, + refusals: Vec, completed: Vec, }, /// The round budget ran out, or the model finished without a submit. The @@ -1224,6 +1336,7 @@ pub(super) enum SeedCheckpoint { command_id: CommandId, session: SeedSession, agent_prompt: String, + refusals: Vec, completed: Vec, gaps: Vec, }, @@ -1251,9 +1364,9 @@ impl SeedCheckpoint { self, Self::SeedInFlight { completed, - last_mismatches, + refusals, .. - } if completed.is_empty() && last_mismatches.is_empty() + } if completed.is_empty() && refusals.is_empty() ) } @@ -1262,7 +1375,7 @@ impl SeedCheckpoint { Self::SeedInFlight { command_id, agent_prompt, - last_mismatches, + refusals, completed, invocation, .. @@ -1271,7 +1384,7 @@ impl SeedCheckpoint { && canonical_model(&invocation.invocation.request.model) && match evaluate_elaboration_loop( agent_prompt, - last_mismatches, + refusals, completed, SEED_ROUND_BUDGET, ) { @@ -1294,21 +1407,17 @@ impl SeedCheckpoint { } Self::ReadyToSubmit { agent_prompt, - last_mismatches, + refusals, completed, .. - } => derive_elaboration_capture( - agent_prompt, - last_mismatches, - completed, - SEED_ROUND_BUDGET, - ) - .is_ok_and(|capture| capture.submitted), + } => derive_elaboration_capture(agent_prompt, refusals, completed, SEED_ROUND_BUDGET) + .is_ok_and(|capture| capture.submitted), Self::NoPatch { agent_prompt, + refusals, completed, .. - } => derive_elaboration_capture(agent_prompt, &[], completed, SEED_ROUND_BUDGET) + } => derive_elaboration_capture(agent_prompt, refusals, completed, SEED_ROUND_BUDGET) .is_ok_and(|capture| !capture.submitted), } } @@ -1324,26 +1433,39 @@ pub(super) fn valid_seed_progression(existing: &SeedCheckpoint, next: &SeedCheck ( SeedCheckpoint::SeedInFlight { completed: existing, + refusals: existing_refusals, .. }, SeedCheckpoint::SeedInFlight { - completed: next, .. + completed: next, + refusals: next_refusals, + .. } | SeedCheckpoint::ReadyToSubmit { - completed: next, .. + completed: next, + refusals: next_refusals, + .. } | SeedCheckpoint::NoPatch { - completed: next, .. + completed: next, + refusals: next_refusals, + .. }, - ) => next.len() >= existing.len() && next.starts_with(existing), - // A rejection reopens the same command id for repair. A rejected - // command mutates nothing, so the round evidence starts over. + ) => next.starts_with(existing) && next_refusals.starts_with(existing_refusals), + // A rejection reopens the same command id for repair in the same + // conversation: the rounds stay, and one more refusal follows them. ( - SeedCheckpoint::ReadyToSubmit { .. }, - SeedCheckpoint::SeedInFlight { - last_mismatches, .. + SeedCheckpoint::ReadyToSubmit { + completed: existing, + refusals: existing_refusals, + .. }, - ) => !last_mismatches.is_empty(), + SeedCheckpoint::SeedInFlight { + completed: next, + refusals: next_refusals, + .. + }, + ) => refusal_reopens(existing, existing_refusals, next, next_refusals), _ => false, } } @@ -1457,24 +1579,24 @@ impl SeedRunner { } }; - let (agent_prompt, mut last_mismatches, mut completed) = match existing { + let (agent_prompt, mut refusals, mut completed) = match existing { Some(SeedCheckpoint::NoPatch { .. }) => return Ok(SeedOutcome::NoPatch), Some( SeedCheckpoint::SeedInFlight { agent_prompt, - last_mismatches, + refusals, completed, .. } | SeedCheckpoint::ReadyToSubmit { agent_prompt, - last_mismatches, + refusals, completed, .. }, - ) => (agent_prompt, last_mismatches, completed), + ) => (agent_prompt, refusals, completed), None => ( - build_seed_prompt(&snapshot, &session, self.brief.as_deref(), &receipts, &[]), + build_seed_prompt(&snapshot, &session, self.brief.as_deref(), &receipts), Vec::new(), Vec::new(), ), @@ -1483,7 +1605,7 @@ impl SeedRunner { loop { match evaluate_elaboration_loop( &agent_prompt, - &last_mismatches, + &refusals, &completed, SEED_ROUND_BUDGET, )? { @@ -1500,14 +1622,14 @@ impl SeedRunner { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), - last_mismatches: last_mismatches.clone(), + refusals: refusals.clone(), completed: completed.clone(), invocation: invocation.clone(), }) .await?; self.inference.lend_tool_results( &invocation, - Box::new(ElaborationOracle::new(&completed, SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&completed, &refusals, SEED_ROUND_BUDGET)), ); let output = self.inference.infer(invocation).await.map_err(|source| { ControllerError::Inference { @@ -1533,6 +1655,7 @@ impl SeedRunner { command_id, session, agent_prompt, + refusals, completed, gaps: capture.gaps, }) @@ -1543,7 +1666,7 @@ impl SeedRunner { command_id, session: session.clone(), agent_prompt: agent_prompt.clone(), - last_mismatches: last_mismatches.clone(), + refusals: refusals.clone(), completed: completed.clone(), }) .await?; @@ -1575,15 +1698,11 @@ impl SeedRunner { mismatches = ?mismatches, "seed patch rejected; a repair prompt is persisted for the next step" ); - last_mismatches = mismatches; - let repaired = build_seed_prompt( - &snapshot, - &session, - self.brief.as_deref(), - &receipts, - &last_mismatches, - ); - self.persist_repair(command_id, &session, &repaired, &last_mismatches) + refusals.push(Refusal { + after_round: completed.len(), + mismatches, + }); + self.persist_repair(command_id, &session, &agent_prompt, &refusals, &completed) .await?; Ok(SeedOutcome::Rejected) } @@ -1632,19 +1751,19 @@ impl SeedRunner { command_id: CommandId, session: &SeedSession, agent_prompt: &str, - last_mismatches: &[Mismatch], + refusals: &[Refusal], + completed: &[InferenceOutput], ) -> Result<(), ControllerError> { let conversation = - match evaluate_elaboration_loop(agent_prompt, last_mismatches, &[], SEED_ROUND_BUDGET)? - { + match evaluate_elaboration_loop(agent_prompt, refusals, completed, SEED_ROUND_BUDGET)? { ElaborationLoopEvaluation::Continue { conversation } => conversation, ElaborationLoopEvaluation::Complete { .. } => { return Err(ControllerError::Serialization( - "a repair round completed before any evidence".into(), + "a refused session finalized instead of reopening".into(), )); } }; - let request = seed_request(command_id, 0, &self.model, conversation)?; + let request = seed_request(command_id, completed.len(), &self.model, conversation)?; let invocation = self.inference .prepare(request) @@ -1656,8 +1775,8 @@ impl SeedRunner { command_id, session: session.clone(), agent_prompt: agent_prompt.to_owned(), - last_mismatches: last_mismatches.to_vec(), - completed: Vec::new(), + refusals: refusals.to_vec(), + completed: completed.to_vec(), invocation, }) .await @@ -1745,7 +1864,6 @@ fn build_seed_prompt( session: &SeedSession, brief: Option<&str>, receipts: &[EvidenceReceipt], - last_mismatches: &[Mismatch], ) -> String { let mut prompt = String::new(); prompt.push_str("You are seeding a world before it opens. Nothing here is running yet.\n\n"); @@ -1776,12 +1894,13 @@ fn build_seed_prompt( if let Some(brief) = brief.map(str::trim).filter(|value| !value.is_empty()) { prompt.push_str(&format!("{brief}\n")); } + prompt.push_str(&render_clock(snapshot)); prompt.push_str( "\nA subject qualifies only when it has a controller, at least one affordance grant, and holds a goal commitment. Declare people, institutions, and populations who want something; give each a controller, grants, a position, and a goal. Give them the rest of a life: routines and obligations that recur, counterparties, channels they speak on and who controls them, authority and the offices that lend it, holdings and the dependencies those holdings serve, routes between the places they move through. A subject with no counterparty who can command or litigate its goal leaves a boundary for the elaborator; that is allowed and expected, not an error.\nYou may not declare a human-controlled subject: only the world's first person is human, and that was genesis.\n\n", ); prompt.push_str(&render_world_structure(snapshot)); prompt.push('\n'); - prompt_body(&mut prompt, receipts, last_mismatches); + prompt_body(&mut prompt, receipts); prompt } @@ -1988,7 +2107,7 @@ mod tests { ]; let round_zero = output(calls.clone()); - let mut oracle = ElaborationOracle::new(&[], SEED_ROUND_BUDGET); + let mut oracle = ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET); assert_eq!(oracle.remaining_rounds() as usize, SEED_ROUND_BUDGET); let answers: Vec = calls .iter() @@ -2020,7 +2139,7 @@ mod tests { serde_json::json!({"detail": "after"}), ), ]; - let mut seeded = ElaborationOracle::new(&[round_zero.clone()], SEED_ROUND_BUDGET); + let mut seeded = ElaborationOracle::new(&[round_zero.clone()], &[], SEED_ROUND_BUDGET); assert_eq!( seeded.remaining_rounds() as usize, SEED_ROUND_BUDGET - 1, @@ -2038,7 +2157,7 @@ mod tests { "gap recorded".to_string(), ] ); - let mut replayed = ElaborationOracle::new(&[], SEED_ROUND_BUDGET); + let mut replayed = ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET); let straight: Vec = calls .iter() .chain(terminal.iter()) @@ -2086,11 +2205,11 @@ mod tests { fn soul_the_two_authoring_lanes_carry_different_budgets_on_the_oracle() { assert_ne!(ELABORATION_ROUND_BUDGET, SEED_ROUND_BUDGET); assert_eq!( - ElaborationOracle::new(&[], ELABORATION_ROUND_BUDGET).remaining_rounds() as usize, + ElaborationOracle::new(&[], &[], ELABORATION_ROUND_BUDGET).remaining_rounds() as usize, ELABORATION_ROUND_BUDGET ); assert_eq!( - ElaborationOracle::new(&[], SEED_ROUND_BUDGET).remaining_rounds() as usize, + ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET).remaining_rounds() as usize, SEED_ROUND_BUDGET ); // A spent budget reports zero rather than wrapping. Zero is outside the @@ -2105,7 +2224,7 @@ mod tests { }) .collect(); assert_eq!( - ElaborationOracle::new(&spent, ELABORATION_ROUND_BUDGET).remaining_rounds(), + ElaborationOracle::new(&spent, &[], ELABORATION_ROUND_BUDGET).remaining_rounds(), 0 ); } @@ -2153,6 +2272,101 @@ mod tests { assert!(capture.gaps[2].detail.contains("submitted twice")); } + /// A refusal keeps the conversation and restarts the draft: the rounds + /// before it stay in view as the model's own calls, the refusal is the next + /// user turn, and only the calls after it are the patch. The oracle folds + /// to the same place, so the sidecar's answers and the evaluator's capture + /// cannot disagree across the boundary. + #[test] + fn a_refusal_restarts_the_draft_inside_the_same_conversation() { + let first = output(vec![ + ("declare_place", place("north")), + (SUBMIT_PATCH_TOOL, serde_json::json!({})), + ]); + let refusals = vec![Refusal { + after_round: 1, + mismatches: vec![Mismatch::NoCanonicalChange], + }]; + + // Reopened: the refusal is the last turn and nothing has finalized. + let ElaborationLoopEvaluation::Continue { conversation } = evaluate_elaboration_loop( + "prompt", + &refusals, + std::slice::from_ref(&first), + ELABORATION_ROUND_BUDGET, + ) + .unwrap() else { + panic!("a refused round finalized instead of reopening"); + }; + let CodexInputItem::UserText { text } = conversation.last().unwrap() else { + panic!("the refusal is not the next user turn"); + }; + assert!(text.contains("The world refused the patch you submitted")); + assert!(text.contains(&render_mismatch(&Mismatch::NoCanonicalChange))); + assert!( + matches!(&conversation[1], CodexInputItem::ToolCall { name, .. } if name == "declare_place"), + "the refused round's calls left the conversation" + ); + assert_eq!( + ElaborationOracle::new(&[first.clone()], &refusals, ELABORATION_ROUND_BUDGET) + .fold + .draft, + WorldPatch::default(), + "the oracle kept the refused draft" + ); + + // Repaired: the capture is exactly the calls after the refusal. + let second = output(vec![ + ("declare_place", place("south")), + (SUBMIT_PATCH_TOOL, serde_json::json!({})), + ]); + let capture = derive_elaboration_capture( + "prompt", + &refusals, + &[first, second], + ELABORATION_ROUND_BUDGET, + ) + .unwrap(); + assert!(capture.submitted); + assert_eq!(capture.draft.declarations.len(), 1); + assert_eq!(capture.inference_receipts.len(), 2, "both rounds are receipts"); + } + + /// The runner places refusals, so a misplaced one is a corrupt checkpoint: + /// before any round, past the rounds that exist, out of order, empty, or + /// after a round that never submitted. + #[test] + fn a_misplaced_refusal_is_refused_as_evidence() { + let submitted = output(vec![ + ("declare_place", place("north")), + (SUBMIT_PATCH_TOOL, serde_json::json!({})), + ]); + let unsubmitted = output(vec![("declare_place", place("north"))]); + let refusal = |after_round: usize| Refusal { + after_round, + mismatches: vec![Mismatch::NoCanonicalChange], + }; + let evaluate = |refusals: &[Refusal], completed: &[InferenceOutput]| { + evaluate_elaboration_loop("prompt", refusals, completed, ELABORATION_ROUND_BUDGET) + .is_err() + }; + assert!(evaluate(&[refusal(0)], std::slice::from_ref(&submitted))); + assert!(evaluate(&[refusal(2)], std::slice::from_ref(&submitted))); + assert!(evaluate( + &[refusal(2), refusal(1)], + &[submitted.clone(), submitted.clone()] + )); + assert!(evaluate( + &[Refusal { + after_round: 1, + mismatches: Vec::new(), + }], + std::slice::from_ref(&submitted) + )); + assert!(evaluate(&[refusal(1)], std::slice::from_ref(&unsubmitted))); + assert!(!evaluate(&[refusal(1)], std::slice::from_ref(&submitted))); + } + /// Soul, pass 10. `session_command_id` was rewritten onto /// `CommandId::derived`, and the two spellings hash different byte streams: /// the old one hashed one msgpack tuple of `(world, jurisdiction, digest)`, diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index 75ff6cd..a84d2e8 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -1162,7 +1162,7 @@ pub(crate) enum PatchAnswer { Deficit(JurisdictionKey), } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub(crate) struct WorldPatch { pub(crate) declarations: Vec, @@ -5429,7 +5429,7 @@ pub(crate) const SUBMIT_PATCH_TOOL: &str = "submit"; pub(crate) const PATCH_TOOLS: &[PatchTool] = &[ PatchTool { name: "declare_subject", - description: "Declare a new subject: a person, an institution, or a population.", + description: "Declare a new subject: a person, an institution, or a population. Every subject needs at least one affordance grant, by the bracketed id of a standing affordance or the handle of one declared in this patch; a subject with none cannot act and is refused.", fields: &[ field("handle", PatchFieldKind::Handle), field("label", PatchFieldKind::Label), @@ -5977,7 +5977,10 @@ fn field_schema(kind: PatchFieldKind) -> Value { PatchFieldKind::Quantity => tool_schema::bounded_integer(1, u64::MAX), PatchFieldKind::Cost => tool_schema::bounded_integer(1, u64::from(MAX_ROUTE_COST)), PatchFieldKind::Magnitude => tool_schema::bounded_integer(1, u64::from(u32::MAX)), - PatchFieldKind::Minutes => tool_schema::bounded_integer(0, u64::MAX), + PatchFieldKind::Minutes => tool_schema::described( + tool_schema::bounded_integer(0, u64::MAX), + "a reading of the world clock in fictional minutes; the prompt states the current reading, and a due must be later than it", + ), PatchFieldKind::OptionalPeriod => { tool_schema::nullable(tool_schema::bounded_integer(1, u64::from(MAX_ROUTE_COST))) } diff --git a/crates/ghostlight-dungeon/src/world/sdk_inference.rs b/crates/ghostlight-dungeon/src/world/sdk_inference.rs index 191fce1..bfc793d 100644 --- a/crates/ghostlight-dungeon/src/world/sdk_inference.rs +++ b/crates/ghostlight-dungeon/src/world/sdk_inference.rs @@ -995,7 +995,7 @@ mod tests { let prepared = seed_prepared(&port, prompt); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let output = port.infer(prepared).await.expect("the seed query returns"); @@ -1036,7 +1036,7 @@ mod tests { let prepared = seed_prepared(&port, "Author the shortfall."); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let fault = port .infer(prepared) @@ -1137,7 +1137,7 @@ mod tests { let prepared = seed_prepared(&port, "Author the shortfall."); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let fault = port .infer(prepared) @@ -1168,7 +1168,7 @@ mod tests { let prepared = seed_prepared(&port, "Author the shortfall."); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let output = port .infer(prepared) @@ -1510,7 +1510,7 @@ mod tests { let prepared = prose_prepared(&port, "Say something true."); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let fault = port .infer(prepared) @@ -1528,7 +1528,7 @@ mod tests { .expect("the stale invocation builds"); port.lend_tool_results( &stale, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); assert!(port.infer(stale.clone()).await.is_err()); assert!( @@ -1540,7 +1540,7 @@ mod tests { let other = SdkInferencePort::new(ScriptedLink::new(Vec::new()), "ghostlight-other"); other.lend_tool_results( &stale, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let fault = other .infer(stale) @@ -1562,7 +1562,7 @@ mod tests { let prepared = seed_prepared(&port, "Author the shortfall."); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); port.infer(prepared).await.expect("the empty query returns"); assert!(port.oracles.lock().unwrap().is_empty()); @@ -1656,7 +1656,7 @@ mod tests { let prepared = seed_prepared(&port, "Author the shortfall."); port.lend_tool_results( &prepared, - Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)), + Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)), ); let fault = port .infer(prepared) diff --git a/crates/ghostlight-dungeon/src/world/tool_schema.rs b/crates/ghostlight-dungeon/src/world/tool_schema.rs index 59cb657..ce15274 100644 --- a/crates/ghostlight-dungeon/src/world/tool_schema.rs +++ b/crates/ghostlight-dungeon/src/world/tool_schema.rs @@ -47,6 +47,15 @@ pub(super) fn bounded_integer(min: u64, max: u64) -> Value { json!({"type": "integer", "minimum": min, "maximum": max}) } +/// The same schema with the text the model reads beside it. Meaning that lives +/// only in a prompt paragraph is lost by the time the model fills the field. +pub(super) fn described(mut schema: Value, description: &str) -> Value { + if let Value::Object(fields) = &mut schema { + fields.insert("description".into(), Value::String(description.into())); + } + schema +} + pub(super) fn name_enum(values: &[&str]) -> Value { json!({"type": "string", "enum": values}) } diff --git a/docs/architecture/ghostlight-world-ontology.md b/docs/architecture/ghostlight-world-ontology.md index cac7d4e..9ce4c41 100644 --- a/docs/architecture/ghostlight-world-ontology.md +++ b/docs/architecture/ghostlight-world-ontology.md @@ -186,8 +186,12 @@ tool surface is `PATCH_TOOLS`: one tool per non-genesis `Declaration` and `world/tool_schema.rs` from one schema spelling per type. `world/elaboration.rs` owns the repair loop: a session keyed by a deterministic command id (sha256 over world, jurisdiction, answer digest) -submits a draft, persists the resolver's complete mismatch set under that id, -and re-prompts with it, bounded by `ELABORATION_ROUND_BUDGET` and the +submits a draft, persists the resolver's complete mismatch set under that id +as a `Refusal` placed after the rounds that earned it, and continues the same +conversation: the refusal is the next user turn, the model's own calls stay in +view above it so every mismatch site names something it can see, and the draft +is rebuilt from the calls after the refusal. Both authoring lanes share this +fold, bounded by `ELABORATION_ROUND_BUDGET` and the `MAX_DRAFT_*` size caps. Evidence a model cites must come from the round's `EvidenceSource` receipts (`filter_evidence`); with `NullEvidenceSource` no canonical fact and no `Admit` can land from the elaborator lane. @@ -282,7 +286,7 @@ knowledge row, and never another subject's snapshot. The re-lowering's provider request is a distinct round (`interpreter_round`) with its own content-addressed request id, so it cannot collide with the first lowering's request. State schema is `ghostlight.world_state.consumer.v2`, commit schema -`ghostlight.world_commit.consumer.v2`, controller work `controller_work.v11`, +`ghostlight.world_commit.consumer.v2`, controller work `controller_work.v12`, Persona turn receipt `ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are refused. Ghostlight owns a conserved narrative ledger; Delvehold owns the economy (`delvehold-forced-ontology-integration.md`). diff --git a/notes/ghostlight-implementation-plan.md b/notes/ghostlight-implementation-plan.md index 6fd4f53..b6dfe31 100644 --- a/notes/ghostlight-implementation-plan.md +++ b/notes/ghostlight-implementation-plan.md @@ -610,6 +610,23 @@ pass-through; the decoder reports it as missing. The result-subtype mapping (`error_max_turns` is an output; every error subtype a named fault) is tested in the sidecar without a credential. +Road follow-ups, 2026-09-10, from the first two SDK-backed seeded runs +(`notes/local-live-smoke.md`, "Claude SDK route"): the sidecar's schema +conversion now carries every field `description` through to the advertised +tool schema (it dropped all of them, so the `claude` lane saw bare types); +`due` describes itself as a clock reading and both authoring prompts print +the clock, because the model wrote twelve commitments due at or before a +clock it was never shown; `declare_subject` states the grant rule at the +point of use; `apply_tool_call` logs each raw tool call at debug so a refused +patch is diagnosed from what the model sent. A kernel refusal now continues +the same conversation instead of opening a fresh draft: `Refusal { after_round, +mismatches }` rows on both authoring checkpoints (`controller_work.v12`; +prior rows refused, not migrated), the refusal rendered as the next user turn +after the rounds that earned it, and the draft rebuilt by one `DraftFold` +shared by the evaluator and the oracle from the calls after it. The harness +takes the connector binding only when `GHOSTLIGHT_CONTROLLER_CONNECTOR` is +set, so an all-`claude` run needs no connector. + ## Subtraction budget Prefer deletion, collapse, or reuse before adding surfaces. The replacement diff --git a/notes/local-live-smoke.md b/notes/local-live-smoke.md index 02ab75f..f3d81f7 100644 --- a/notes/local-live-smoke.md +++ b/notes/local-live-smoke.md @@ -194,10 +194,27 @@ vault plus either a running CodexConnector or a built Claude SDK sidecar with an ambient Claude Code login; see `GHOSTLIGHT_SMOKE_*` and `GHOSTLIGHT_SDK_*` environment." -This route has not been run. This machine has neither the Claude Code CLI on -PATH nor a stored credential, so nothing beyond the scripted-link unit tests -in `world/sdk_inference.rs` and the checked-in schema/frame fixtures has -exercised it. +### SDK runs, 2026-09-10 + +Every lane on `claude-sonnet-5`, Kalsa `Public`, Low Sere, target six, two +seed sessions, three ticks. The operator installed the CLI and logged in +interactively; the sidecar inherited the login. No connector variables were +set. + +| Run | Seed | Ticks | Result | +|---|---|---|---| +| 1 | both sessions refused: subjects with no grant, twelve commitments due at or before the clock | 3 committed, 2 cells each, Persona spoke | harness failed on the empty seed; the transport itself worked end to end | +| 2 | one session, one round, 89 s: six qualified persons, deficit 6 to 0 | 456 s, 395 s, 326 s; 8 singleton cells each; revision 3 to 24 | passed; tick 3 quarantined one cell | + +Rules run 1 imposed are in the implementation plan under step 11 (descriptions +carried through the sidecar, `due` described and the clock printed, the grant +rule on the subject tool, raw tool calls logged, refusal continues the +conversation). Run 2's two findings: three of eight speech captures were cut +mid-word ("just t", "a?\" I") because the Interpreter cited byte spans the +model counted wrong, and the tick-3 quarantine was a mis-spanned utterance +failing the canonical-text rule at invocation, which is a translation gap +wearing an infrastructure fault's coat. Ticks are slow because eight singleton +cells run in sequence at roughly 50 s per cell on this lane. ## Interrupted cell diff --git a/sidecar/claude-sdk/src/schema.ts b/sidecar/claude-sdk/src/schema.ts index a3d0b67..ef99264 100644 --- a/sidecar/claude-sdk/src/schema.ts +++ b/sidecar/claude-sdk/src/schema.ts @@ -34,7 +34,19 @@ function keep(schema: z.ZodTypeAny): z.ZodTypeAny { return schema.catch(undefined as never); } +/** + * The text the emitter put beside a field is the model's only account of what + * the field means; the first SDK road run lost every description here and the + * model filled `due` blind. + */ function convert(node: unknown, at: string): z.ZodTypeAny { + const converted = convertShape(node, at); + return isObject(node) && typeof node.description === "string" + ? converted.describe(node.description) + : converted; +} + +function convertShape(node: unknown, at: string): z.ZodTypeAny { if (!isObject(node)) { throw new Error(`${at}: schema node is not an object`); } diff --git a/sidecar/claude-sdk/test/schema.test.ts b/sidecar/claude-sdk/test/schema.test.ts index 261062a..473a58d 100644 --- a/sidecar/claude-sdk/test/schema.test.ts +++ b/sidecar/claude-sdk/test/schema.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { z } from "zod"; import { toolInputSchema } from "../src/schema.ts"; const emitted: { name: string; parameters_json: string }[] = JSON.parse( @@ -24,6 +25,26 @@ test("every emitted tool schema converts", () => { } }); +test("a property's description reaches the advertised schema", () => { + const schema = toolInputSchema( + JSON.stringify({ + type: "object", + additionalProperties: false, + required: ["due", "kind"], + properties: { + due: { type: "integer", minimum: 0, maximum: 9, description: "a clock reading" }, + kind: { type: "string", enum: ["a"], description: "which shape" }, + }, + }), + "described", + ); + const advertised = z.toJSONSchema(schema, { io: "input" }) as { + properties: Record; + }; + assert.equal(advertised.properties.due?.description, "a clock reading"); + assert.equal(advertised.properties.kind?.description, "which shape"); +}); + test("a converted schema carries every property's own type", () => { const schema = toolInputSchema( JSON.stringify({ diff --git a/sidecar/claude-sdk/test/schemas.json b/sidecar/claude-sdk/test/schemas.json index 597afe8..30c1bb5 100644 --- a/sidecar/claude-sdk/test/schemas.json +++ b/sidecar/claude-sdk/test/schemas.json @@ -53,7 +53,7 @@ }, { "name": "create_commitment", - "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"checks\":{\"items\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"at\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"precondition\":{\"const\":\"present\",\"type\":\"string\"}},\"required\":[\"precondition\",\"at\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"precondition\":{\"const\":\"reachable\",\"type\":\"string\"},\"to\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"within\":{\"maximum\":525600,\"minimum\":1,\"type\":\"integer\"}},\"required\":[\"precondition\",\"to\",\"within\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"at_least\":{\"maximum\":18446744073709551615,\"minimum\":1,\"type\":\"integer\"},\"precondition\":{\"const\":\"holds\",\"type\":\"string\"},\"resource\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing resource\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"precondition\",\"resource\",\"at_least\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"kind\":{\"description\":\"the authority kind\",\"type\":\"string\"},\"over\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"subject\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"place_subtree\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"}]},\"precondition\":{\"const\":\"authorized\",\"type\":\"string\"}},\"required\":[\"precondition\",\"over\",\"kind\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"grievance\":{\"description\":\"the grievance kind\",\"type\":\"string\"},\"precondition\":{\"const\":\"has_standing\",\"type\":\"string\"}},\"required\":[\"precondition\",\"grievance\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"at_least\":{\"enum\":[\"doubted\",\"believed\",\"certain\"],\"type\":\"string\"},\"fact\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing fact\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"precondition\":{\"const\":\"knows\",\"type\":\"string\"}},\"required\":[\"precondition\",\"fact\",\"at_least\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"precondition\":{\"const\":\"can_broadcast\",\"type\":\"string\"},\"via\":{\"anyOf\":[{\"const\":\"colocated\",\"type\":\"string\"},{\"additionalProperties\":false,\"properties\":{\"channel\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing channel\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"channel\"],\"type\":\"object\"}]}},\"required\":[\"precondition\",\"via\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"precondition\":{\"const\":\"can_reach\",\"type\":\"string\"},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"via\":{\"anyOf\":[{\"const\":\"colocated\",\"type\":\"string\"},{\"additionalProperties\":false,\"properties\":{\"channel\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing channel\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"channel\"],\"type\":\"object\"}]}},\"required\":[\"precondition\",\"subject\",\"via\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"kind\":{\"enum\":[\"routine\",\"obligation\",\"goal\"],\"type\":\"string\"},\"precondition\":{\"const\":\"committed\",\"type\":\"string\"},\"to\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"precondition\",\"to\",\"kind\"],\"type\":\"object\"}]},\"type\":\"array\"},\"counterparty\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},{\"type\":\"null\"}]},\"due\":{\"maximum\":18446744073709551615,\"minimum\":0,\"type\":\"integer\"},\"kind\":{\"enum\":[\"routine\",\"obligation\",\"goal\"],\"type\":\"string\"},\"period\":{\"anyOf\":[{\"maximum\":525600,\"minimum\":1,\"type\":\"integer\"},{\"type\":\"null\"}]},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\",\"counterparty\",\"kind\",\"due\",\"period\",\"checks\"],\"type\":\"object\"}" + "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"checks\":{\"items\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"at\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"precondition\":{\"const\":\"present\",\"type\":\"string\"}},\"required\":[\"precondition\",\"at\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"precondition\":{\"const\":\"reachable\",\"type\":\"string\"},\"to\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"within\":{\"maximum\":525600,\"minimum\":1,\"type\":\"integer\"}},\"required\":[\"precondition\",\"to\",\"within\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"at_least\":{\"maximum\":18446744073709551615,\"minimum\":1,\"type\":\"integer\"},\"precondition\":{\"const\":\"holds\",\"type\":\"string\"},\"resource\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing resource\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"precondition\",\"resource\",\"at_least\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"kind\":{\"description\":\"the authority kind\",\"type\":\"string\"},\"over\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"subject\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"place_subtree\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"}]},\"precondition\":{\"const\":\"authorized\",\"type\":\"string\"}},\"required\":[\"precondition\",\"over\",\"kind\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"grievance\":{\"description\":\"the grievance kind\",\"type\":\"string\"},\"precondition\":{\"const\":\"has_standing\",\"type\":\"string\"}},\"required\":[\"precondition\",\"grievance\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"at_least\":{\"enum\":[\"doubted\",\"believed\",\"certain\"],\"type\":\"string\"},\"fact\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing fact\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"precondition\":{\"const\":\"knows\",\"type\":\"string\"}},\"required\":[\"precondition\",\"fact\",\"at_least\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"precondition\":{\"const\":\"can_broadcast\",\"type\":\"string\"},\"via\":{\"anyOf\":[{\"const\":\"colocated\",\"type\":\"string\"},{\"additionalProperties\":false,\"properties\":{\"channel\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing channel\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"channel\"],\"type\":\"object\"}]}},\"required\":[\"precondition\",\"via\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"precondition\":{\"const\":\"can_reach\",\"type\":\"string\"},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"via\":{\"anyOf\":[{\"const\":\"colocated\",\"type\":\"string\"},{\"additionalProperties\":false,\"properties\":{\"channel\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing channel\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"channel\"],\"type\":\"object\"}]}},\"required\":[\"precondition\",\"subject\",\"via\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"kind\":{\"enum\":[\"routine\",\"obligation\",\"goal\"],\"type\":\"string\"},\"precondition\":{\"const\":\"committed\",\"type\":\"string\"},\"to\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"precondition\",\"to\",\"kind\"],\"type\":\"object\"}]},\"type\":\"array\"},\"counterparty\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},{\"type\":\"null\"}]},\"due\":{\"description\":\"a reading of the world clock in fictional minutes; the prompt states the current reading, and a due must be later than it\",\"maximum\":18446744073709551615,\"minimum\":0,\"type\":\"integer\"},\"kind\":{\"enum\":[\"routine\",\"obligation\",\"goal\"],\"type\":\"string\"},\"period\":{\"anyOf\":[{\"maximum\":525600,\"minimum\":1,\"type\":\"integer\"},{\"type\":\"null\"}]},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\",\"counterparty\",\"kind\",\"due\",\"period\",\"checks\"],\"type\":\"object\"}" }, { "name": "declare_affordance",