Let agent tools own legal action schemas
This commit is contained in:
parent
acd544bae6
commit
deaa37eee3
6 changed files with 421 additions and 149 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue