Let agent tools own legal action schemas

This commit is contained in:
Meta Vens 2026-08-28 22:29:15 +02:00
commit deaa37eee3
6 changed files with 421 additions and 149 deletions

View file

@ -11,7 +11,6 @@ pub struct ModelAgentSpec {
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>,
@ -73,6 +72,11 @@ pub trait ModelAgentTool: Send {
type Output: Send;
type Finding: Serialize + Send;
/// The tool owns the action space that is legal at its current private
/// workbench state. The agent loop asks again before every model step so a
/// rejected action can narrow the next call to the exact repair surface.
fn action_schema(&self) -> std::result::Result<serde_json::Value, String>;
async fn invoke(
&mut self,
action: Self::Action,
@ -96,19 +100,44 @@ pub async fn run_model_agent<Tool: ModelAgentTool>(
let mut transcript = String::new();
let mut last_observation = None;
for step in 0..spec.max_steps {
let action_schema = match tool.action_schema() {
Ok(schema) => schema,
Err(message) => {
return Err(ModelAgentFailure {
message: format!(
"model agent {} tool could not publish its current action schema: {message}",
spec.stage
),
receipts,
});
}
};
let action_schema_text = match serde_json::to_string(&action_schema) {
Ok(schema) => schema,
Err(error) => {
return Err(ModelAgentFailure {
message: format!(
"model agent {} could not serialize its current action schema: {error}",
spec.stage
),
receipts,
});
}
};
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.",
"CURRENT LEGAL TOOL ACTION SCHEMA:\n{}\n\n{}{}\n\nAGENT STEP: {} of {}. Choose one typed tool action admitted by the current schema. 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.",
action_schema_text,
spec.instructions,
transcript,
step + 1,
spec.max_steps,
),
output_schema: Some(spec.action_schema.clone()),
output_schema: Some(action_schema),
source_receipt_ids,
temperature: spec.temperature,
max_output_tokens: spec.max_output_tokens,
@ -337,6 +366,10 @@ mod tests {
type Output = String;
type Finding = ExactFinding;
fn action_schema(&self) -> std::result::Result<serde_json::Value, String> {
serde_json::to_value(schema_for!(CandidateAction)).map_err(|error| error.to_string())
}
async fn invoke(
&mut self,
action: Self::Action,
@ -365,6 +398,54 @@ mod tests {
}
}
struct NarrowingTool {
draft_stored: bool,
}
#[async_trait]
impl ModelAgentTool for NarrowingTool {
type Action = CandidateAction;
type Output = String;
type Finding = ExactFinding;
fn action_schema(&self) -> std::result::Result<serde_json::Value, String> {
let value = if self.draft_stored {
"accepted"
} else {
"drafted"
};
Ok(serde_json::json!({
"type":"object",
"additionalProperties":false,
"required":["value"],
"properties":{"value":{"const":value}}
}))
}
async fn invoke(
&mut self,
action: Self::Action,
_context: &ModelAgentToolContext,
) -> ModelAgentToolOutcome<Self::Output, Self::Finding> {
if self.draft_stored {
assert_eq!(action.value, "accepted");
ModelAgentToolOutcome::Accepted {
output: action.value,
receipts: Vec::new(),
}
} else {
assert_eq!(action.value, "drafted");
self.draft_stored = true;
ModelAgentToolOutcome::Continue {
observation: ExactFinding::DraftStored {
value: action.value,
},
receipts: Vec::new(),
}
}
}
}
fn fixture_tool_receipt(value: &str) -> ModelStageReceipt {
ModelStageReceipt {
schema: "ghostlight.model_stage_receipt.v1".into(),
@ -399,7 +480,6 @@ mod tests {
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),
@ -444,7 +524,6 @@ mod tests {
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),
@ -469,6 +548,49 @@ mod tests {
);
}
#[tokio::test]
async fn tool_state_owns_each_steps_exact_action_schema() {
let model = ScriptedModel {
outputs: Mutex::new(vec![
r#"{"value":"drafted"}"#.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 current tool contract.".into(),
source_receipt_ids: vec!["source:one".into()],
temperature: Some(0.0),
max_output_tokens: Some(128),
max_steps: 2,
};
let mut tool = NarrowingTool {
draft_stored: false,
};
let run = run_model_agent(&model, &spec, &mut tool).await.unwrap();
assert_eq!(run.output, "accepted");
let requests = model.requests.lock().unwrap();
assert_eq!(
requests[0].output_schema.as_ref().unwrap()["properties"]["value"]["const"],
"drafted"
);
assert_eq!(
requests[1].output_schema.as_ref().unwrap()["properties"]["value"]["const"],
"accepted"
);
assert!(
requests[1]
.lived_stream
.contains("CURRENT LEGAL TOOL ACTION SCHEMA")
);
assert!(requests[1].lived_stream.contains(r#""const":"accepted""#));
}
#[tokio::test]
async fn exhausted_agent_preserves_every_action_and_tool_receipt() {
let model = ScriptedModel {
@ -483,7 +605,6 @@ mod tests {
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),

View file

@ -855,6 +855,7 @@ struct DestinationReconciliationAgentTool<'a> {
existing_destination_id: Option<&'a str>,
snapshot: &'a str,
admission_evidence_receipt_ids: &'a [String],
action_schema: serde_json::Value,
}
enum CivicSystemVerificationOutcome {
@ -3018,7 +3019,6 @@ impl WorldCompiler {
model: MODEL_BALANCED.into(),
snapshot_binding: binding,
instructions,
action_schema: schema,
source_receipt_ids: causal_source_receipt_ids.to_vec(),
temperature: Some(0.0),
max_output_tokens: Some(2_500),
@ -3036,6 +3036,7 @@ impl WorldCompiler {
existing_destination_id,
snapshot,
admission_evidence_receipt_ids,
action_schema: schema,
};
run_model_agent(self.model.as_ref(), &spec, &mut tool).await
}
@ -3407,6 +3408,10 @@ impl ModelAgentTool for DestinationReconciliationAgentTool<'_> {
type Output = AcceptedDestinationReconciliation;
type Finding = DestinationReconciliationObservation;
fn action_schema(&self) -> std::result::Result<serde_json::Value, String> {
Ok(self.action_schema.clone())
}
async fn invoke(
&mut self,
action: Self::Action,

View file

@ -1211,6 +1211,12 @@ impl crate::agent::ModelAgentTool for WorldElaborationAgentTool<'_> {
type Output = WorldElaborationProposal;
type Finding = WorldElaborationAgentFinding;
fn action_schema(&self) -> std::result::Result<serde_json::Value, String> {
self.assignment
.action_schema(self.target_location_id)
.map_err(|error| error.to_string())
}
async fn invoke(
&mut self,
action: Self::Action,
@ -1299,12 +1305,6 @@ impl ElaborationSubAgentPort<WorldElaborationProposal> for ModelWorldElaboration
model: model.into(),
snapshot_binding,
instructions,
action_schema: assignment
.action_schema(&self.target_location_id)
.map_err(|error| ElaborationSubAgentFailure {
diagnostic: error.to_string(),
model_stage_receipts: Vec::new(),
})?,
source_receipt_ids: Vec::new(),
temperature: Some(0.4),
max_output_tokens: Some(1_800),

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@ pub struct InterpreterPrompt<'a> {
pub typed_context: &'a str,
pub lived_stream: &'a str,
pub persona_output: &'a str,
pub output_schema: &'a str,
pub output_schema: Option<&'a str>,
pub domain_guidance: &'a str,
}
@ -52,14 +52,19 @@ pub fn build_persona_prompt(input: &PersonaPrompt<'_>) -> String {
}
pub fn build_interpreter_prompt(input: &InterpreterPrompt<'_>) -> String {
let output_contract = match input.output_schema {
Some(schema) => format!(
"Return exactly one JSON object matching this stable shape:\n{schema}"
),
None => "The agent harness supplies the current legal typed action schema immediately before each step. Return exactly one action matching that schema; do not reuse a command from an earlier step.".into(),
};
format!(
"<!-- membrane:{MEMBRANE_SCHEMA}:interpreter -->\nYou are a private Interpreter. Convert a natural Persona turn into typed candidate effects supported by the lived stream and permissioned typed context. Candidates are proposals only; the owning runtime validates and commits them. Do not invent knowledge, capability, custody, perception, identifiers, state references, or completed consequences.\n\nReturn exactly one JSON object matching this stable shape:\n{schema}\n\nDomain guidance and exact permissions:\n{guidance}\n\nIdentity:\n{identity}\n\nPermissioned typed context:\n{context}\n\nLived stream:\n{stream}\n\nPersona turn:\n{output}",
"<!-- membrane:{MEMBRANE_SCHEMA}:interpreter -->\nYou are a private Interpreter. Convert a natural Persona turn into typed candidate effects supported by the lived stream and permissioned typed context. Candidates are proposals only; the owning runtime validates and commits them. Do not invent knowledge, capability, custody, perception, identifiers, state references, or completed consequences.\n\n{output_contract}\n\nDomain guidance and exact permissions:\n{guidance}\n\nIdentity:\n{identity}\n\nPermissioned typed context:\n{context}\n\nLived stream:\n{stream}\n\nPersona turn:\n{output}",
identity = input.identity,
guidance = input.domain_guidance,
context = input.typed_context,
stream = input.lived_stream,
output = input.persona_output,
schema = input.output_schema,
)
}

View file

@ -194,22 +194,22 @@ code bounds it before correction and never publishes private cell choices to a
player error surface. A rejected wave returns one spoiler-free message while
its exact diagnostic remains operator-only.
The Interpreter is a bounded model agent over a private workbench. Its efficient
first action may submit the complete exact decision map only while the draft is
empty. A rejected draft stays in the tool; later actions may upsert or remove
only the exact decisions named as missing or rejected, or inspect the draft.
Its action wire is one strict root object containing one exact typed command.
The nested command union binds each `kind` to only its own payload, so a model
cannot emit a submit mixed with incremental-edit fields. The workbench remains
the only owner allowed to apply that structurally legal command to the draft.
The tool structurally rejects whole-draft replacement and edits to unrelated
accepted decisions. Local validation returns a typed `local_validation`
finding and the semantic verifier returns exact subject-scoped mismatch
findings. Neither validator chooses the replacement or gains commit authority.
Verifier matches are cached by the exact snapshot-and-action binding, so
repairing one action does not repay inference for unchanged accepted actions.
An `undecided` result returns to the Persona owner instead of letting the
Interpreter invent a choice.
The Interpreter is a bounded model agent over a private workbench. Every
model-agent workbench owns the action schema legal at its current state; the
provider-neutral loop asks the tool for that schema before every semantic step
and places the same schema in both prompt guidance and the structured response
boundary. For an empty Interpreter draft, the only command is one complete
exact `submit`. A rejected draft remains private tool state, and the next schema
contains only `upsert_decision` commands for exact missing or rejected owners.
There is no inspection, removal, wholesale resubmit, or unrelated repair branch
for the model to spend a step on. The action wire remains one strict root object
containing one exact typed command. Local validation returns a typed
`local_validation` finding and the semantic verifier returns exact
subject-scoped mismatch findings. Neither validator chooses the replacement or
gains commit authority. Verifier matches are cached by the exact
snapshot-and-action binding, so repairing one action does not repay inference
for unchanged accepted actions. An `undecided` result returns to the Persona
owner instead of letting the Interpreter invent a choice.
The strategic scheduler owns partial-wave recovery. Its typed checkpoint binds
the exact campaign revision, resolution epoch, deterministic cover and plan,
@ -982,16 +982,19 @@ reject but cannot rewrite or commit the proposal.
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.
`ModelAgentSpec`, and a consumer-owned `ModelAgentTool`. The spec supplies the
stage, logical model class, frozen snapshot binding, instructions, causal
receipt IDs, per-call settings, and semantic step limit. Before every step, the
tool publishes the action schema legal at its current private state. Static
tools may route a contract from their actual domain owner; world elaboration,
for example, delegates schema derivation to `WorldElaborationAssignment`. The
harness outputs 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.
Once the destination compiler has produced a repairable frozen civic candidate,
that exact seed initializes one private workbench owned by