feat(mcp): every result carries the envelope (P5)

Agent reason: an agent should never have to guess what follows a call. One
place (call_tool, after the tool answers) now gives every tool result the
same four fields: next_steps (what to do next, each with why and whether it
is required), guards (the named refusal and the tool that remedies it),
warnings, and, on any tool that is not read-only, head (where the journal
ends after the call's last write). A tool's own field of the same name is
kept, never overwritten; the text block says what the structured content
says.

Next steps come from the tool's own result (a preview gets its confirm step
with the digest; a post is followed by showing the entry and, only if it
was in error, its reversal; a draft by its lines; a statement by marking
its lines and reconciling; a period list by the close) and, after a change
to an entry or an invoice, from the viewer's own Needs-attention items about
that subject, so the agent and the human read one list.

guards.rs is the registry of refusals (stale_preview,
format_upgrade_pending, receipt_required, approval_required, party_shares,
party_required, open_drafts, untagged_control_line, duplicate_vendor_ref,
first_number_locked), each naming a tool that exists. A refusal whose text
carries a guard gets it as structuredContent.guards; its text is unchanged.

Tests: every_mutating_result_has_head_and_next_steps (the whole router),
the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard,
every_guard_names_an_existing_tool, a_refusal_becomes_its_guard,
every_step_a_tool_names_exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:48:27 -06:00
1 parent 169b914e1f
commit cf73d0bda5
5 files changed
+803

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
//! The guards: the named ways the books refuse a change, each with the
//! tool that remedies it.
//!
//! ```text
//! GOAL: an agent that is refused is told which rule refused it and which
//! tool to call next, in the same words every time.
//! SOURCE: plan/served.md 4.6 (the result envelope: `guards` carry a code,
//! a message and a remedy tool); DECISION (P5).
//! INVARIANTS:
//! 1. Every guard names a remedy tool that exists (test
//! `every_guard_names_an_existing_tool`).
//! 2. A code is spelled one way: the enum's `code()`; a refusal whose
//! text is `{"code": .., "message": ..}` JSON or says a code in words
//! becomes the guard of that code, and any other refusal has none.
//! ```
use serde_json::{Value, json};
/// Every guard code the books know.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Guard {
/// The preview a confirmation carries is not the one the books would
/// show now.
StalePreview,
/// The books wait for the owner's format upgrade.
FormatUpgradePending,
/// The evidence policy wants a receipt before this posts.
ReceiptRequired,
/// An approval rule wants an approver's go-ahead.
ApprovalRequired,
/// The entry's party shares do not add up.
PartyShares,
/// The parties setting requires a party (or a no-party reason).
PartyRequired,
/// Drafts stand in the way of a close.
OpenDrafts,
/// A line on a control account carries no document tag.
UntaggedControlLine,
/// A vendor already has a bill with this reference.
DuplicateVendorRef,
/// The first invoice number cannot change once one is issued.
FirstNumberLocked,
}
impl Guard {
/// Every guard, for the registry test.
pub(crate) const ALL: [Guard; 10] = [
Guard::StalePreview,
Guard::FormatUpgradePending,
Guard::ReceiptRequired,
Guard::ApprovalRequired,
Guard::PartyShares,
Guard::PartyRequired,
Guard::OpenDrafts,
Guard::UntaggedControlLine,
Guard::DuplicateVendorRef,
Guard::FirstNumberLocked,
];
/// The code an agent reads.
pub(crate) fn code(self) -> &'static str {
match self {
Guard::StalePreview => "stale_preview",
Guard::FormatUpgradePending => "format_upgrade_pending",
Guard::ReceiptRequired => "receipt_required",
Guard::ApprovalRequired => "approval_required",
Guard::PartyShares => "party_shares",
Guard::PartyRequired => "party_required",
Guard::OpenDrafts => "open_drafts",
Guard::UntaggedControlLine => "untagged_control_line",
Guard::DuplicateVendorRef => "duplicate_vendor_ref",
Guard::FirstNumberLocked => "first_number_locked",
}
}
/// The guard of a code.
pub(crate) fn from_code(code: &str) -> Option<Guard> {
Self::ALL.into_iter().find(|g| g.code() == code)
}
/// The tool that remedies it; `tool` is the call that was refused (a
/// stale preview is remedied by previewing it again).
pub(crate) fn remedy_tool(self, tool: &str) -> String {
match self {
Guard::StalePreview => return tool.to_string(),
Guard::FormatUpgradePending => "books.start",
Guard::ReceiptRequired => "books.attach_receipt",
Guard::ApprovalRequired => "books.approve_entry",
Guard::PartyShares | Guard::PartyRequired => "books.set_entry_party",
Guard::OpenDrafts => "books.list_drafts",
Guard::UntaggedControlLine => "books.control_check",
Guard::DuplicateVendorRef => "books.search_entries",
Guard::FirstNumberLocked => "books.settings",
}
.to_string()
}
}
/// The guards a refusal's text carries, as the envelope shows them:
/// `[{code, message, remedy_tool, remedy_args?}]`. `tool` is the refused
/// call.
pub(crate) fn from_refusal(tool: &str, text: &str) -> Vec<Value> {
// A structured refusal: the JSON the tool built.
if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(text)
&& let Some(code) = o.get("code").and_then(Value::as_str)
{
let remedy = o
.get("remedy_tool")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| Guard::from_code(code).map(|g| g.remedy_tool(tool)));
if let Some(remedy) = remedy {
let mut g = json!({
"code": code,
"message": o.get("message").and_then(Value::as_str).unwrap_or(code),
"remedy_tool": remedy,
});
if let Some(entry) = o.get("entry_id") {
g["remedy_args"] = json!({ "entry_id": entry });
}
return vec![g];
}
return Vec::new();
}
// A refusal that names its code in words (the store's).
for g in Guard::ALL {
if text.starts_with(g.code()) {
return vec![json!({
"code": g.code(),
"message": text,
"remedy_tool": g.remedy_tool(tool),
})];
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::BooksServer;
#[test]
fn every_guard_names_an_existing_tool() {
let names: Vec<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for g in Guard::ALL {
let remedy = g.remedy_tool("books.post_entry");
assert!(
names.contains(&remedy),
"guard {} names {remedy}, which is no tool",
g.code()
);
assert_eq!(Guard::from_code(g.code()), Some(g));
}
}
#[test]
fn a_refusal_becomes_its_guard() {
let text = json!({"code":"approval_required","message":"needs approval","entry_id":7,"remedy_tool":"books.approve_entry"}).to_string();
let g = from_refusal("books.post_entry", &text);
assert_eq!(g[0]["code"], "approval_required");
assert_eq!(g[0]["remedy_args"]["entry_id"], 7);
let g = from_refusal(
"books.create_invoice",
"format_upgrade_pending: this needs documents_v2",
);
assert_eq!(g[0]["remedy_tool"], "books.start");
assert!(from_refusal("books.x", "no such account").is_empty());
}
}