Add provider-neutral model agent harness

This commit is contained in:
Meta Vens 2026-08-28 01:11:18 +02:00
commit 842e265f29
5 changed files with 1505 additions and 856 deletions

View file

@ -0,0 +1,425 @@
use crate::model::{
ModelPort, ModelStageReceipt, ModelStageRequest, mark_model_receipt_semantic_invalid,
run_validated_stage,
};
use async_trait::async_trait;
use serde::{Serialize, de::DeserializeOwned};
use std::collections::BTreeSet;
pub struct ModelAgentSpec {
pub stage: String,
pub model: String,
pub snapshot_binding: String,
pub instructions: String,
pub action_schema: serde_json::Value,
pub source_receipt_ids: Vec<String>,
pub temperature: Option<f64>,
pub max_output_tokens: Option<u32>,
pub max_steps: usize,
}
#[derive(Debug)]
pub struct ModelAgentRun<Output> {
pub output: Output,
pub receipts: Vec<ModelStageReceipt>,
}
#[derive(Debug)]
pub struct ModelAgentFailure {
pub message: String,
pub receipts: Vec<ModelStageReceipt>,
}
impl std::fmt::Display for ModelAgentFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ModelAgentFailure {}
pub struct ModelAgentToolContext {
pub source_receipt_ids: Vec<String>,
}
pub enum ModelAgentToolOutcome<Output, Finding> {
Accepted {
output: Output,
receipts: Vec<ModelStageReceipt>,
},
Rejected {
finding: Finding,
receipts: Vec<ModelStageReceipt>,
},
Failed {
message: String,
receipts: Vec<ModelStageReceipt>,
},
}
#[async_trait]
pub trait ModelAgentTool: Send {
type Action: DeserializeOwned + Serialize + Send;
type Output: Send;
type Finding: Serialize + Send;
async fn invoke(
&mut self,
action: Self::Action,
context: &ModelAgentToolContext,
) -> ModelAgentToolOutcome<Self::Output, Self::Finding>;
}
pub async fn run_model_agent<Tool: ModelAgentTool>(
port: &dyn ModelPort,
spec: &ModelAgentSpec,
tool: &mut Tool,
) -> std::result::Result<ModelAgentRun<Tool::Output>, ModelAgentFailure> {
if spec.max_steps == 0 {
return Err(ModelAgentFailure {
message: format!("model agent {} has no semantic step budget", spec.stage),
receipts: Vec::new(),
});
}
let mut receipts = Vec::new();
let mut transcript = String::new();
let mut last_finding = None;
for step in 0..spec.max_steps {
let source_receipt_ids = causal_source_ids(&spec.source_receipt_ids, &receipts);
let request = ModelStageRequest {
stage: spec.stage.clone(),
model: spec.model.clone(),
snapshot_binding: spec.snapshot_binding.clone(),
lived_stream: format!(
"{}{}\n\nAGENT STEP: {} of {}. Choose one typed tool action. The harness will execute it against the frozen state and return the real tool observation. Do not claim success yourself; only an accepted tool result can end the task.",
spec.instructions,
transcript,
step + 1,
spec.max_steps,
),
output_schema: Some(spec.action_schema.clone()),
source_receipt_ids,
temperature: spec.temperature,
max_output_tokens: spec.max_output_tokens,
};
let mut stage = match run_validated_stage(port, &request).await {
Ok(stage) => stage,
Err(error) => {
return Err(ModelAgentFailure {
message: format!(
"model agent {} transport or schema stage failed: {error}",
spec.stage
),
receipts,
});
}
};
let action_value = match stage.structured.take() {
Some(value) => value,
None => {
let finding = serde_json::json!({
"kind":"action_decode_error",
"message":"agent returned no typed action",
});
mark_model_receipt_semantic_invalid(&mut stage.receipt, &finding);
transcript.push_str(&tool_observation(step, &serde_json::Value::Null, &finding));
last_finding = Some(finding);
receipts.push(stage.receipt);
continue;
}
};
let action = match serde_json::from_value::<Tool::Action>(action_value.clone()) {
Ok(action) => action,
Err(error) => {
let finding = serde_json::json!({
"kind":"action_decode_error",
"message":error.to_string(),
});
mark_model_receipt_semantic_invalid(&mut stage.receipt, &finding);
transcript.push_str(&tool_observation(step, &action_value, &finding));
last_finding = Some(finding);
receipts.push(stage.receipt);
continue;
}
};
let tool_context = ModelAgentToolContext {
source_receipt_ids: causal_source_ids(
&spec.source_receipt_ids,
&receipts
.iter()
.cloned()
.chain(std::iter::once(stage.receipt.clone()))
.collect::<Vec<_>>(),
),
};
match tool.invoke(action, &tool_context).await {
ModelAgentToolOutcome::Accepted {
output,
receipts: tool_receipts,
} => {
receipts.push(stage.receipt);
receipts.extend(tool_receipts);
return Ok(ModelAgentRun { output, receipts });
}
ModelAgentToolOutcome::Rejected {
finding,
receipts: tool_receipts,
} => {
let finding = match serde_json::to_value(finding) {
Ok(finding) => finding,
Err(error) => {
let message = format!(
"model agent {} could not serialize its typed tool finding: {error}",
spec.stage
);
mark_model_receipt_semantic_invalid(&mut stage.receipt, &message);
receipts.push(stage.receipt);
receipts.extend(tool_receipts);
return Err(ModelAgentFailure { message, receipts });
}
};
mark_model_receipt_semantic_invalid(&mut stage.receipt, &finding);
transcript.push_str(&tool_observation(step, &action_value, &finding));
last_finding = Some(finding);
receipts.push(stage.receipt);
receipts.extend(tool_receipts);
}
ModelAgentToolOutcome::Failed {
message,
receipts: tool_receipts,
} => {
mark_model_receipt_semantic_invalid(&mut stage.receipt, &message);
receipts.push(stage.receipt);
receipts.extend(tool_receipts);
return Err(ModelAgentFailure { message, receipts });
}
}
}
Err(ModelAgentFailure {
message: format!(
"model agent {} exhausted {} semantic steps; final tool finding: {}",
spec.stage,
spec.max_steps,
last_finding
.map(|finding| finding.to_string())
.unwrap_or_else(|| "none".into()),
),
receipts,
})
}
fn causal_source_ids(base: &[String], receipts: &[ModelStageReceipt]) -> Vec<String> {
base.iter()
.cloned()
.chain(
receipts
.iter()
.map(|receipt| receipt.storage_key().to_owned()),
)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn tool_observation(
step: usize,
rejected_action: &serde_json::Value,
finding: &serde_json::Value,
) -> String {
format!(
"\n\nTOOL OBSERVATION AFTER AGENT STEP {}:\n{}\nPREVIOUS REJECTED ACTION:\n{}\nReturn the next complete typed tool action. Preserve frozen identity and useful valid work; change what the finding actually rejects.",
step + 1,
serde_json::to_string(finding).unwrap_or_else(|_| "null".into()),
serde_json::to_string(rejected_action).unwrap_or_else(|_| "null".into()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{MODEL_BALANCED, ModelProviderOutput};
use anyhow::Result;
use async_trait::async_trait;
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct CandidateAction {
value: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum ExactFinding {
WrongValue { received: String },
}
struct ScriptedModel {
outputs: Mutex<Vec<String>>,
requests: Mutex<Vec<ModelStageRequest>>,
}
#[async_trait]
impl ModelPort for ScriptedModel {
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
self.requests.lock().unwrap().push(request.clone());
Ok(self.outputs.lock().unwrap().remove(0))
}
async fn run_observed(&self, request: &ModelStageRequest) -> Result<ModelProviderOutput> {
Ok(ModelProviderOutput {
content: self.run(request).await?,
resolved_model: Some("gpt-5.6-terra".into()),
..Default::default()
})
}
fn provider(&self) -> &'static str {
"fixture"
}
}
struct ExactTool;
#[async_trait]
impl ModelAgentTool for ExactTool {
type Action = CandidateAction;
type Output = String;
type Finding = ExactFinding;
async fn invoke(
&mut self,
action: Self::Action,
_context: &ModelAgentToolContext,
) -> ModelAgentToolOutcome<Self::Output, Self::Finding> {
if action.value == "accepted" {
ModelAgentToolOutcome::Accepted {
output: action.value,
receipts: Vec::new(),
}
} else {
ModelAgentToolOutcome::Rejected {
finding: ExactFinding::WrongValue {
received: action.value.clone(),
},
receipts: vec![fixture_tool_receipt(&action.value)],
}
}
}
}
fn fixture_tool_receipt(value: &str) -> ModelStageReceipt {
ModelStageReceipt {
schema: "ghostlight.model_stage_receipt.v1".into(),
receipt_hash: String::new(),
provider: "fixture-tool".into(),
model: "deterministic".into(),
stage: "fixture_tool_validation".into(),
snapshot_binding: "snapshot:1".into(),
request_hash: format!("request:{value}"),
output_hash: format!("tool:{value}"),
source_receipt_ids: Vec::new(),
latency_ms: 0,
validation_result: "semantic_invalid".into(),
local_validation_error: Some(format!("rejected {value}")),
input_chars: 0,
output_chars: 0,
provider_attempts: Vec::new(),
}
}
#[tokio::test]
async fn semantic_tool_observation_drives_a_causally_receipted_second_action() {
let model = ScriptedModel {
outputs: Mutex::new(vec![
r#"{"value":"rejected"}"#.into(),
r#"{"value":"accepted"}"#.into(),
]),
requests: Mutex::new(Vec::new()),
};
let spec = ModelAgentSpec {
stage: "fixture_agent".into(),
model: MODEL_BALANCED.into(),
snapshot_binding: "snapshot:1".into(),
instructions: "Use the tool.".into(),
action_schema: serde_json::to_value(schema_for!(CandidateAction)).unwrap(),
source_receipt_ids: vec!["source:one".into()],
temperature: Some(0.0),
max_output_tokens: Some(128),
max_steps: 2,
};
let run = run_model_agent(&model, &spec, &mut ExactTool)
.await
.unwrap();
assert_eq!(run.output, "accepted");
assert_eq!(run.receipts.len(), 3);
assert_eq!(run.receipts[0].validation_result, "semantic_invalid");
assert_eq!(run.receipts[1].stage, "fixture_tool_validation");
assert_eq!(run.receipts[2].validation_result, "valid");
assert!(
run.receipts[2]
.source_receipt_ids
.contains(&run.receipts[0].storage_key().to_owned())
);
assert!(
run.receipts[2]
.source_receipt_ids
.contains(&run.receipts[1].storage_key().to_owned())
);
let requests = model.requests.lock().unwrap();
assert!(requests[1].lived_stream.contains("wrong_value"));
assert!(
requests[1]
.lived_stream
.contains("PREVIOUS REJECTED ACTION")
);
}
#[tokio::test]
async fn exhausted_agent_preserves_every_action_and_tool_receipt() {
let model = ScriptedModel {
outputs: Mutex::new(vec![
r#"{"value":"first"}"#.into(),
r#"{"value":"second"}"#.into(),
]),
requests: Mutex::new(Vec::new()),
};
let spec = ModelAgentSpec {
stage: "fixture_agent".into(),
model: MODEL_BALANCED.into(),
snapshot_binding: "snapshot:1".into(),
instructions: "Use the tool.".into(),
action_schema: serde_json::to_value(schema_for!(CandidateAction)).unwrap(),
source_receipt_ids: vec!["source:one".into()],
temperature: Some(0.0),
max_output_tokens: Some(128),
max_steps: 2,
};
let failure = run_model_agent(&model, &spec, &mut ExactTool)
.await
.unwrap_err();
assert!(failure.message.contains("exhausted 2 semantic steps"));
assert_eq!(failure.receipts.len(), 4);
assert_eq!(failure.receipts[0].validation_result, "semantic_invalid");
assert_eq!(failure.receipts[1].stage, "fixture_tool_validation");
assert_eq!(failure.receipts[2].validation_result, "semantic_invalid");
assert_eq!(failure.receipts[3].stage, "fixture_tool_validation");
assert!(
failure.receipts[2]
.source_receipt_ids
.contains(&failure.receipts[0].storage_key().to_owned())
);
assert!(
failure.receipts[2]
.source_receipt_ids
.contains(&failure.receipts[1].storage_key().to_owned())
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
#![recursion_limit = "256"]
pub mod agency_corpus;
pub mod agent;
pub mod assessor;
pub mod compiler;
pub mod consumer;

View file

@ -130,6 +130,14 @@ impl ModelStageReceipt {
}
}
pub(crate) fn mark_model_receipt_semantic_invalid(
receipt: &mut ModelStageReceipt,
finding: &impl std::fmt::Display,
) {
receipt.validation_result = "semantic_invalid".into();
receipt.local_validation_error = Some(finding.to_string().chars().take(1_000).collect());
}
#[derive(Clone, Debug, PartialEq)]
pub struct ModelStageOutput {
pub narrative: String,

View file

@ -937,38 +937,66 @@ the office, procedure, or answer. Inhabited proposals must expose current
authority, selection or succession, public resources, and redress through a
typed civic manifest whose exact fact references are shared by each resident
population. A separate model stage verifies their semantic legibility; it may
reject but cannot rewrite or commit the proposal. One false verdict is
independently rechecked against the same frozen candidate.
reject but cannot rewrite or commit the proposal.
Apply-time admission, ordinary structural validation, and the independent civic
verifier publish findings into one pending civic-reconciliation lane. A single
scheduler owns the shared budget: one balanced reconciliation plus at most one
balanced correction of a rejected reconciliation. The failure sites do not own
retry policy, and no path can return to the expensive `destination_compile`
stage. Every later attempt cites the completed compiler, reconciliation, and
verifier receipts that caused it; rejected reconciliation receipts remain
`semantic_invalid` evidence.
Destination repair is the first consumer of the generic model-agent harness.
The harness owner is `run_model_agent`, operating only over `ModelPort`, a
`ModelAgentSpec`, and a consumer-owned `ModelAgentTool`. Its inputs are a stage,
logical model class, frozen snapshot binding, instructions, action schema,
causal receipt IDs, per-call settings, and a semantic step limit. Its outputs
are one accepted consumer-owned value plus the complete receipt chain, or one
terminal failure plus the receipts completed before failure. Step transcript,
tool observations, causal source-ID accumulation, and `semantic_invalid`
marking are derived run state. The harness has no campaign, fact, institution,
civic, kernel, or persistence type and cannot validate or mutate world state.
Patina or another consumer may supply a different action and tool without
moving that consumer's state authority into the harness.
The Sol destination candidate remains the owner of every institution identity,
name, and goal. Terra may replace candidate-owned civic facts, resident civic
fact references, local political relations, and the civic manifest, and may
propose operational updates for every exact frozen institution ID. The request
schema fixes both the update count and the permitted ID set.
`apply_civic_reconciliation` is the sole merge and admission owner: it rejects
omitted, duplicate, substituted, or invented institution IDs and copies only
resources, posture, location scope, facets, and information channels onto the
frozen institutions. Geography, routes, migration, population identity,
non-civic facts, institution identity, institution names, institution goals,
branch assumptions, gaps, civic version, and semantic receipt binding have no
writable field in that pass. A finding outside that writable slice can fail
honestly but cannot induce a second expensive compilation. The merged candidate
must survive the ordinary destination validators and a fresh independent
verdict. The kernel rebinds the accepted verdict to the exact candidate before
mutation admission. Completed compiler, reconciliation, and verifier receipts
survive terminal failure and are persisted by both the strategic harness and
the Dungeon API. Accepted civic systems persist as versioned component state,
so a later pass receives the current institutions, populations, facts, and
relations and must extend that apparatus instead of inventing a parallel
Once the destination compiler has produced a repairable frozen civic candidate,
that exact seed is the agent's immutable base. Candidate lowering, fact-namespace
admission, ordinary structural validation, and the independent civic verdict
all feed one `DestinationReconciliationAgentTool`. The balanced-model agent may
submit at most four complete `validate_candidate` actions. Every rejected
action receives the exact tool finding and the next action cites all prior
compiler, action, and verifier receipts. Rejected action and verifier receipts
remain `semantic_invalid`. The removed civic scheduler, pending candidate, and
manual reconciliation-attempt state are not writers; after the agent starts,
no path returns to `destination_compile`.
The consumer-owned tool is the validation and merge boundary. Sol's frozen
seed owns geography, routes, migration, population identity and ordinary state,
institution identity/name/goals, branch assumptions, and gaps. Terra may replace
the complete candidate fact set while preserving its count, every exact resident
population's fact-ID set, local political relations, the civic manifest, and
operational fields for every exact frozen institution ID. The schema fixes fact,
population, and institution entry counts and enumerates the permitted population
and institution IDs. `destination_fact_findings` is the sole candidate-fact
namespace owner and runs before merge; the ordinary destination validator reuses
that same function at its admission boundary. `apply_civic_reconciliation` owns
the frozen jurisdiction, exact population and institution update sets, candidate
fact count, fact-reference authority, and writable-field merge. It copies only
the admitted fields onto a clone of the frozen seed. Lowering and the ordinary
destination validator remain domain owners. An accepted candidate is still only
a compilation preview; `WorldKernel` and the mutation reducer remain the sole
world-state admission and commit authorities.
`ModelAgentTool` has associated typed `Action`, `Finding`, and `Output` contracts.
The harness serializes a consumer's finding only when constructing the next
model observation; it cannot invent or interpret a domain finding. The civic
verifier performs one inference for each exact candidate and returns its typed
verdict and receipt to the tool. Only `run_model_agent` owns semantic candidate
retry and its four-step budget. The initial candidate plus four rejected agent
actions can therefore produce at most five civic-verifier receipts. Provider
transport and schema retries remain the separately named lower-level authority
of `run_validated_stage`; they do not select or repair a civic candidate.
Accepted verifier receipts are rebound to the exact candidate before preview
admission. Completed compiler, action, tool, and verifier receipts survive
terminal failure and are persisted by both the strategic harness and the
Dungeon API. Generic harness tests prove that a later action cites both the
rejected action and tool receipts, and that exhaustion returns every completed
action and tool receipt. Accepted civic systems persist as versioned component
state, so a later pass receives the current institutions, populations, facts,
and relations and must extend that apparatus instead of inventing a parallel
government.
Both destination paths synthesize the smallest compatible branch-local routes,