Add the batch decoder and fix the actor vocabulary (PA.f54, PA.f55, PA.f58)

PA.f54, ruling: in a round, each maximal run of consecutive authoring
calls is one patch, committed atomically under one command id, with
draft handles shared inside the run. Adds `decode_authoring_calls`,
which decodes a whole run into one shared `WorldPatch` plus a
`site_calls` map from each declaration/operation site back to the call
index that produced it, so a later call's reference to an earlier
call's draft handle resolves within the run and a decode error names
its own call index. `decode_authoring_call` stays as the one-call entry
point with its own unprefixed error text; both now read the same
`decode_one_authoring_call`, so there is one decoder rather than two
copies.

PA.f55: `actor_tools` offered `record_need` and `finish_without_proposal`
through `catalog_tools`, but those end a turn — the controller lane's own
vocabulary — and `decode_actor_call` has no arm for either, so the play
agent could be handed a tool call it would always refuse. Splits
`catalog_tools` into `affordance_tools` (one tool per granted entry, no
turn-enders) plus the two pushes `catalog_tools` still makes for its own
callers; `actor_tools` now reads `affordance_tools`. `decode_actor_call`
also gains a `prefix` parameter and strips it from the incoming name
before matching, so a name `actor_tools` emits (`{prefix}{kind}`) decodes
as emitted through the one decoder rather than a caller stripping the
prefix itself first.

PA.f58: `decode_actor_call` silently dropped a non-string `display`
(`and_then(Value::as_str)` swallows the wrong type) and the shared
`decode_catalog_call` silently dropped a `text` field on an entry that
carries no speech. Both are now refused. Also widens the
`table_view_never_reaches_persona_or_projector_code` pin from grepping
only `controllers.rs` to scanning every library source file except
`table.rs` for a `table_view(` call.

Dungeon's `play.rs` is adjusted only for the new `decode_actor_call`
signature: its one dispatch call site and one test call site pass `""`
for the new `prefix` parameter, since Dungeon already strips the
`<handle>__` prefix itself before calling. Behaviour is unchanged; Cut 8b
is the one that wires Dungeon's own dispatch through the table's prefix
handling properly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 12:42:43 +02:00
commit 71a5c81240
4 changed files with 312 additions and 33 deletions

View file

@ -833,7 +833,11 @@ impl PlayTable {
Some(RecordedCall::PersonaAct(_, opportunity, invocation)) => (opportunity, invocation),
_ => {
let (opportunity, invocation) =
match decode_actor_call(snapshot, actor.subject, kind, arguments) {
// `kind` is already stripped of its `<handle>__` prefix
// by the `split_once` above, so `""` here decodes it
// unchanged; Cut 8b is the one that wires this dispatch
// through the table's own prefix-stripping properly.
match decode_actor_call(snapshot, actor.subject, "", kind, arguments) {
Ok(decoded) => decoded,
Err(detail) => {
return Ok((RoundOutcome::Continue, format!("refused: {detail}")));
@ -2244,7 +2248,7 @@ mod tests {
let snapshot = fixture.world.snapshot().await.unwrap();
let player = player_id(&snapshot);
let (opportunity, invocation) =
decode_actor_call(&snapshot, player, "speak", &serde_json::json!({"text": "hold fast"}).to_string())
decode_actor_call(&snapshot, player, "", "speak", &serde_json::json!({"text": "hold fast"}).to_string())
.unwrap();
let command_id = derived_command_id(&turn_id, 0, 0);
// The crash window: the kernel already committed the exercise, but

View file

@ -6499,7 +6499,35 @@ pub(super) fn catalog_tools(
prefix: &str,
granted: &[AffordanceSnapshot],
) -> Vec<CodexToolDefinition> {
let mut tools: Vec<CodexToolDefinition> = granted
let mut tools = affordance_tools(prefix, granted);
tools.push(tool_schema::tool(
&format!("{prefix}{RECORD_NEED_TOOL}"),
"Record information or capability the agent would need but does not currently have.",
tool_schema::object(vec![(
"detail".into(),
tool_schema::canonical_string("what the agent would need and does not have"),
)]),
));
tools.push(tool_schema::tool(
&format!("{prefix}{FINISH_WITHOUT_PROPOSAL_TOOL}"),
"Finish this opportunity without proposing an action.",
tool_schema::empty_schema(),
));
tools
}
/// `catalog_tools` minus the two turn-enders: exactly one tool per granted
/// affordance entry, so `decode_catalog_call` — which only ever matches a
/// name against `granted`'s own entries — refuses nothing this vocabulary
/// offers. `record_need` and `finish_without_proposal` end a turn; they are
/// the controller lane's own vocabulary (`decode_catalog_call` has no arm
/// for either), not part of what one entry-granted actor may invoke, so the
/// play table's `actor_tools` reads this and not `catalog_tools`.
pub(super) fn affordance_tools(
prefix: &str,
granted: &[AffordanceSnapshot],
) -> Vec<CodexToolDefinition> {
granted
.iter()
.map(|entry| {
let mut properties: Vec<(String, Value)> = Vec::new();
@ -6539,21 +6567,7 @@ pub(super) fn catalog_tools(
tool_schema::object(properties),
)
})
.collect();
tools.push(tool_schema::tool(
&format!("{prefix}{RECORD_NEED_TOOL}"),
"Record information or capability the agent would need but does not currently have.",
tool_schema::object(vec![(
"detail".into(),
tool_schema::canonical_string("what the agent would need and does not have"),
)]),
));
tools.push(tool_schema::tool(
&format!("{prefix}{FINISH_WITHOUT_PROPOSAL_TOOL}"),
"Finish this opportunity without proposing an action.",
tool_schema::empty_schema(),
));
tools
.collect()
}
/// The same iteration rendered as one prose line, so the prompt's tool list and
@ -6693,6 +6707,9 @@ pub(super) fn decode_catalog_call(
.ok_or_else(|| "`text` is missing or not a string".to_owned())?;
Some(Statement::new(text).ok_or_else(|| "`text` is not canonical".to_owned())?)
} else {
if fields.contains_key("text") {
return Err("this entry carries no speech but `text` was proposed".to_owned());
}
None
};
Ok(DecisionInvocation {

View file

@ -120,7 +120,10 @@ pub(crate) use patch::{
EffectSlot, OutcomeBand, Precondition, RoleSpec,
};
pub use sdk_inference::{DEFAULT_SDK_MODEL_PREFIX, SdkBinding};
pub use table::{TableError, actor_tools, authoring_tools, decode_actor_call, decode_authoring_call, describe_refusal, table_view};
pub use table::{
DecodedBatch, PatchSite, TableError, actor_tools, authoring_tools, decode_actor_call,
decode_authoring_call, decode_authoring_calls, describe_refusal, table_view,
};
pub use vault::{VaultError, VaultEvidenceSource};
use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch};

View file

@ -59,11 +59,70 @@ pub fn authoring_tools(names: &[&str]) -> Result<Vec<CodexToolDefinition>, Table
}
/// One authoring tool call, decoded to the one-item patch it names: exactly
/// one declaration or one operation, plus any evidence it cites. This is
/// one declaration or one operation, plus any evidence it cites. The one-call
/// case of the same code `decode_authoring_calls` shares
/// (`decode_one_authoring_call`), kept as its own entry point with its own
/// unprefixed error text: `apply_tool_call`'s `Declare`/`Operate` arms and
/// the tests that check one tool's own example have no call index to name.
pub fn decode_authoring_call(name: &str, arguments: &str) -> Result<WorldPatch, String> {
decode_one_authoring_call(name, arguments)
}
/// Where one item in a decoded batch's `patch` came from: a declaration at
/// this index in `patch.declarations`, or an operation at this index in
/// `patch.operations`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum PatchSite {
Declaration(usize),
Operation(usize),
}
/// The result of decoding one maximal run of consecutive authoring calls:
/// the whole run's `WorldPatch`, plus which call produced each site in it.
/// Dungeon commits the run atomically under one command id; draft handles
/// declared by an earlier call in the run resolve against later calls in the
/// same run because every call's items land in the one shared `patch`.
#[derive(Debug)]
pub struct DecodedBatch {
pub patch: WorldPatch,
/// `site_calls[&site]` is the index into `calls` (the slice
/// `decode_authoring_calls` was given) that produced that site. A
/// refusal names its call the same way, through this same index.
pub site_calls: std::collections::BTreeMap<PatchSite, usize>,
}
/// One run of authoring calls, decoded to one shared patch (PA.f54): each
/// call still decodes to exactly one declaration or one operation — the same
/// one-item rule `decode_authoring_call` documents — but every call's item
/// lands in the same `WorldPatch`, so a later call's reference to an earlier
/// call's draft handle resolves within the run. A decode error names the
/// call's index in `calls`, not merely its tool name, because the same name
/// can appear more than once in a run.
pub fn decode_authoring_calls(calls: &[(&str, &str)]) -> Result<DecodedBatch, String> {
let mut patch = WorldPatch::default();
let mut site_calls = std::collections::BTreeMap::new();
for (call_index, (name, arguments)) in calls.iter().enumerate() {
let item = decode_one_authoring_call(name, arguments)
.map_err(|error| format!("call #{call_index} (`{name}`): {error}"))?;
for declaration in item.declarations {
site_calls.insert(PatchSite::Declaration(patch.declarations.len()), call_index);
patch.declarations.push(declaration);
}
for operation in item.operations {
site_calls.insert(PatchSite::Operation(patch.operations.len()), call_index);
patch.operations.push(operation);
}
patch.evidence.extend(item.evidence);
}
Ok(DecodedBatch { patch, site_calls })
}
/// The one decode arm both `decode_authoring_call` and `decode_authoring_calls`
/// read: one authoring tool call to the one-item patch it names. Also
/// `apply_tool_call`'s own decode arm, factored out so it has one owner;
/// `apply_tool_call` calls it for every `Declare` or `Operate` shape rather
/// than carrying a second copy of the same match.
pub fn decode_authoring_call(name: &str, arguments: &str) -> Result<WorldPatch, String> {
fn decode_one_authoring_call(name: &str, arguments: &str) -> Result<WorldPatch, String> {
let Some(entry) = PATCH_TOOLS.iter().find(|entry| entry.name == name) else {
return Err(format!("`{name}` is not a tool of this patch"));
};
@ -140,13 +199,17 @@ fn subject_opportunity<'a>(
Some((opportunity, granted))
}
/// The tool catalog for one subject's own granted affordances: `catalog_tools`
/// over the entries its current opportunity actually grants. An unknown
/// The tool catalog for one subject's own granted affordances: an unknown
/// subject or one with no live opportunity gets an empty catalog rather than
/// an error — there is nothing for it to call.
/// an error — there is nothing for it to call. The table's own actor
/// vocabulary is the subject's granted entries only — `affordance_tools`,
/// never `catalog_tools`. `record_need` and `finish_without_proposal` end a
/// turn; that is the controller lane's own vocabulary, and
/// `decode_actor_call` has no arm for either, so offering them here would
/// hand the play agent a tool `decode_actor_call` always refuses (PA.f55).
pub fn actor_tools(prefix: &str, snapshot: &WorldSnapshot, subject: SubjectId) -> Vec<CodexToolDefinition> {
match subject_opportunity(snapshot, subject) {
Some((_, granted)) => controllers::catalog_tools(prefix, &granted),
Some((_, granted)) => controllers::affordance_tools(prefix, &granted),
None => Vec::new(),
}
}
@ -158,12 +221,20 @@ pub fn actor_tools(prefix: &str, snapshot: &WorldSnapshot, subject: SubjectId) -
/// one, decoded through the same canonical-text rule speech already uses).
/// The opportunity returned is the snapshot's own, never a value the caller
/// supplied, so Dungeon can commit it without ever having constructed one.
///
/// `prefix` is the same prefix `actor_tools` was called with: a name
/// `actor_tools` emits (`{prefix}{kind}`) decodes here as emitted, one
/// decoder rather than a caller stripping the prefix itself before this is
/// reached (PA.f55). An empty prefix strips nothing, so a bare `kind` still
/// decodes unchanged.
pub fn decode_actor_call(
snapshot: &WorldSnapshot,
subject: SubjectId,
prefix: &str,
kind: &str,
arguments: &str,
) -> Result<(DecisionOpportunity, DecisionInvocation), String> {
let kind = kind.strip_prefix(prefix).unwrap_or(kind);
let (opportunity, granted) = subject_opportunity(snapshot, subject)
.ok_or_else(|| "this subject holds no exact opportunity".to_owned())?;
let entry = granted
@ -172,8 +243,11 @@ pub fn decode_actor_call(
.ok_or_else(|| format!("`{kind}` is not granted by this subject's opportunity"))?;
let mut invocation = controllers::decode_catalog_call(entry, arguments)?;
if let Ok(Value::Object(fields)) = serde_json::from_str::<Value>(arguments)
&& let Some(display) = fields.get("display").and_then(Value::as_str)
&& let Some(display) = fields.get("display")
{
let display = display
.as_str()
.ok_or_else(|| "`display` is not a string".to_owned())?;
invocation.display =
Some(Statement::new(display).ok_or_else(|| "`display` is not canonical text".to_owned())?);
}
@ -1280,6 +1354,71 @@ mod tests {
}
}
/// Rule (PA.f54): a later call in one run resolves a draft handle an
/// earlier call in the same run declared, because every call's item lands
/// in the run's one shared `WorldPatch`. `declare_place` has no field a
/// `relocate` call takes (it takes a subject and a route, not a place),
/// so `declare_route`'s `from` is the real field this catalog offers that
/// takes a place reference; it stands for the same claim PA.f54 names.
#[test]
fn decode_authoring_calls_shares_draft_handles_across_one_run() {
let place = serde_json::json!({
"handle": "yard",
"label": "The Cavity Yard",
"container": null,
})
.to_string();
let route = serde_json::json!({
"handle": "ramp",
"label": "The Yard Ramp",
"from": {"ref": "draft", "value": "yard"},
"to": {"ref": "existing", "value": "11111111-1111-4111-8111-111111111111"},
"access": {"access": "public"},
"cost": 1,
})
.to_string();
let batch = decode_authoring_calls(&[("declare_place", &place), ("declare_route", &route)])
.expect("both calls in the run decode together");
assert_eq!(batch.patch.declarations.len(), 2);
assert_eq!(batch.patch.operations.len(), 0);
let Declaration::Route(declared) = &batch.patch.declarations[1] else {
panic!("declare_route decoded to something other than a route declaration");
};
assert_eq!(declared.from, patch::Ref::Draft(patch::DraftHandle::new("yard")));
// The site map: each declaration's index names the call index that
// produced it.
assert_eq!(
batch.site_calls.get(&PatchSite::Declaration(0)),
Some(&0),
"declare_place's declaration must be attributed to call #0"
);
assert_eq!(
batch.site_calls.get(&PatchSite::Declaration(1)),
Some(&1),
"declare_route's declaration must be attributed to call #1"
);
}
/// Rule (PA.f54): a decode error names the failing call's index, not only
/// its tool name — the same name can appear more than once in one run.
#[test]
fn decode_authoring_calls_names_the_failing_call_index() {
let good = serde_json::json!({
"handle": "yard",
"label": "The Cavity Yard",
"container": null,
})
.to_string();
let error = decode_authoring_calls(&[
("declare_place", &good),
("declare_place", "not json at all"),
])
.unwrap_err();
assert!(error.contains("call #1"), "{error}");
assert!(error.contains("declare_place"), "{error}");
}
/// Rule: the elaborator's own decode and the table's decode agree, because
/// the elaborator's arm now calls the table's function rather than
/// carrying a second copy.
@ -1332,7 +1471,7 @@ mod tests {
})
.to_string();
let (opportunity, invocation) =
decode_actor_call(&active, speech.speaker, "whisper", &arguments)
decode_actor_call(&active, speech.speaker, "", "whisper", &arguments)
.expect("whisper decodes against the speaker's own opportunity");
assert_eq!(opportunity, expected);
assert_eq!(
@ -1341,6 +1480,98 @@ mod tests {
);
}
/// Rule (PA.f55): `actor_tools` never offers `record_need` or
/// `finish_without_proposal` — those end a turn, which is the controller
/// lane's own vocabulary, and `decode_actor_call` has no arm for either.
/// Every name it does emit decodes as emitted once the same prefix is
/// handed to `decode_actor_call`.
#[test]
fn actor_tools_offers_only_names_decode_actor_call_accepts() {
let directory = tempfile::tempdir().expect("a temp dir");
let mut kernel = crate::WorldKernel::create(
directory.path().join("world.cc"),
creation(super::CommandId::new(), "PlayActorVocabulary"),
&auth_principal(owner()),
)
.expect("a created world")
.0;
let (speech, active) = speech_world(&mut kernel);
let prefix = "c0__";
let tools = actor_tools(prefix, &active, speech.speaker);
assert!(!tools.is_empty(), "the speaker holds granted affordances");
for tool in &tools {
assert!(
!tool.name.ends_with("record_need") && !tool.name.ends_with("finish_without_proposal"),
"actor_tools must not offer the controller lane's turn-enders: {}",
tool.name
);
}
let arguments = serde_json::json!({
"target": id_text(speech.listener),
"text": "a whispered warning",
})
.to_string();
let (_, invocation) =
decode_actor_call(&active, speech.speaker, prefix, &format!("{prefix}whisper"), &arguments)
.expect("a name actor_tools emits decodes as emitted, prefix included");
assert_eq!(
invocation.speech.as_ref().map(Statement::as_str),
Some("a whispered warning")
);
}
/// Rule (PA.f58): a non-string `display` is refused, not silently
/// dropped.
#[test]
fn decode_actor_call_refuses_a_non_string_display() {
let directory = tempfile::tempdir().expect("a temp dir");
let mut kernel = crate::WorldKernel::create(
directory.path().join("world.cc"),
creation(super::CommandId::new(), "PlayActorDisplay"),
&auth_principal(owner()),
)
.expect("a created world")
.0;
let (speech, active) = speech_world(&mut kernel);
let arguments = serde_json::json!({
"target": id_text(speech.listener),
"text": "a whispered warning",
"display": 7,
})
.to_string();
let error = decode_actor_call(&active, speech.speaker, "", "whisper", &arguments).unwrap_err();
assert!(error.contains("display"), "{error}");
}
/// Rule (PA.f58): `text` on an entry that carries no speech is refused,
/// not silently dropped.
#[test]
fn decode_actor_call_refuses_text_on_a_silent_entry() {
let fixture = play_fixture();
let holder = fixture
.snapshot
.subjects
.iter()
.find(|subject| subject.id == fixture.custody.holder)
.expect("the holder is in the snapshot");
let carry = fixture
.snapshot
.affordances
.iter()
.find(|entry| holder.affordances.contains(&entry.id) && entry.entry.kind.0 == "carry")
.expect("the holder holds the silent `carry` affordance");
let mut object = serde_json::Map::new();
for role in &carry.entry.roles {
object.insert(role.role.0.clone(), Value::String(id_text(fixture.custody.holder)));
}
object.insert("slot_0_qty".into(), Value::from(1));
object.insert("text".into(), Value::String("this affordance carries no speech".into()));
let arguments = Value::Object(object).to_string();
let error = decode_actor_call(&fixture.snapshot, fixture.custody.holder, "", "carry", &arguments)
.unwrap_err();
assert!(error.contains("text"), "{error}");
}
/// Mutation M7.1: if `decode_actor_call` used the snapshot's first
/// opportunity instead of the subject's own, this would no longer hold —
/// `speech.listener` holds a different, later-ordered opportunity.
@ -1366,7 +1597,7 @@ mod tests {
"text": "a returned whisper",
})
.to_string();
let (opportunity, _) = decode_actor_call(&active, speech.listener, "whisper", &arguments)
let (opportunity, _) = decode_actor_call(&active, speech.listener, "", "whisper", &arguments)
.expect("whisper decodes against the listener's own opportunity");
assert_eq!(opportunity, expected);
assert_ne!(
@ -1589,10 +1820,34 @@ mod tests {
/// Persona prompt.
#[test]
fn table_view_never_reaches_persona_or_projector_code() {
let source = include_str!("controllers.rs");
assert!(
!source.contains("table_view"),
"controllers.rs must not call table::table_view"
);
// Every library source file except this one: the structural guard is
// that no other module — not `controllers.rs` alone, which is only
// where `PersonaLane` and the Projector happen to live today — ever
// calls `table_view(`. `PersonaLane`'s own signature takes no text
// that could carry it in, so a call anywhere outside `table.rs`
// would have to be a fresh, wrong wire-up.
const SOURCES: &[(&str, &str)] = &[
("action.rs", include_str!("action.rs")),
("clock.rs", include_str!("clock.rs")),
("consumer.rs", include_str!("consumer.rs")),
("controllers.rs", include_str!("controllers.rs")),
("cover.rs", include_str!("cover.rs")),
("elaboration.rs", include_str!("elaboration.rs")),
("journal.rs", include_str!("journal.rs")),
("lens.rs", include_str!("lens.rs")),
("lib.rs", include_str!("lib.rs")),
("local_inference.rs", include_str!("local_inference.rs")),
("mailbox.rs", include_str!("mailbox.rs")),
("patch.rs", include_str!("patch.rs")),
("sdk_inference.rs", include_str!("sdk_inference.rs")),
("tool_schema.rs", include_str!("tool_schema.rs")),
("vault.rs", include_str!("vault.rs")),
];
for (name, source) in SOURCES {
assert!(
!source.contains("table_view("),
"{name} must not call table::table_view"
);
}
}
}