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

+92
View File
@@ -1528,3 +1528,95 @@ fn a_tool_list_is_cached_only_for_its_caller() {
}
});
}
/// Plan 4.6 (P5 step 1): the envelope is put on every result in one
/// place; a tool that can change the books answers with the journal's
/// `head` and at least one next step, whatever it said itself, and the
/// text block says the same as the structured content.
#[test]
fn every_mutating_result_has_head_and_next_steps() {
use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock};
let dir = unit_dir("envelope");
let server = served(store(&dir));
let (seq, hash) = server.lock().unwrap().head();
let mutating = next_steps::mutating_tools();
assert!(mutating.contains("books.post_entry") && !mutating.contains("books.trial_balance"));
let mut read_only = 0;
for tool in BooksServer::tool_router().list_all() {
let name = tool.name.to_string();
let answer = CallToolResponse::Complete(CallToolResult::structured(
serde_json::json!({"status": "ok"}),
));
let CallToolResponse::Complete(r) = next_steps::apply(&server, &name, None, Ok(answer))
.expect("an answer stays an answer")
else {
panic!("{name}: not a complete answer");
};
let body = r.structured_content.expect("structured");
assert!(body["guards"].is_array() && body["warnings"].is_array(), "{name}");
let ContentBlock::Text(t) = &r.content[0] else {
panic!("{name}: no text block");
};
assert_eq!(
serde_json::from_str::<serde_json::Value>(&t.text).unwrap(),
body,
"{name}: the text says what the structure says"
);
if mutating.contains(&name) {
assert_eq!(body["head"]["seq"], seq, "{name}");
assert_eq!(body["head"]["hash"], hash.as_str(), "{name}");
let steps = body["next_steps"].as_array().expect("next_steps");
assert!(!steps.is_empty(), "{name}: no next step");
for s in steps {
assert!(s["why"].is_string() && s["required"].is_boolean(), "{name}");
}
} else {
read_only += 1;
assert!(body.get("head").is_none(), "{name}: a read has no head");
}
}
assert!(read_only > 20);
}
/// A tool's own `next_steps`, `warnings` and `head` are kept (the
/// envelope fills only what the result lacks), and a refusal that is a
/// guard carries it.
#[test]
fn the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard() {
use rmcp::model::{CallToolResponse, CallToolResult};
let dir = unit_dir("envelope-keeps");
let server = served(store(&dir));
let own = serde_json::json!({
"next_steps": [{"tool": "books.digest", "why": "mine", "required": false}],
"warnings": ["careful"],
"head": {"seq": 0, "hash": "x"},
});
let CallToolResponse::Complete(r) = next_steps::apply(
&server,
"books.post_entry",
None,
Ok(CallToolResponse::Complete(CallToolResult::structured(own.clone()))),
)
.unwrap() else {
panic!()
};
let body = r.structured_content.unwrap();
assert_eq!(body["next_steps"], own["next_steps"]);
assert_eq!(body["warnings"], own["warnings"]);
assert_eq!(body["head"], own["head"]);
let refused = CallToolResponse::Complete(CallToolResult::error(vec![
rmcp::model::ContentBlock::text(
serde_json::json!({"code": "approval_required", "message": "needs approval", "remedy_tool": "books.approve_entry"})
.to_string(),
),
]));
let CallToolResponse::Complete(r) =
next_steps::apply(&server, "books.post_entry", None, Ok(refused)).unwrap()
else {
panic!()
};
assert_eq!(r.is_error, Some(true));
let g = &r.structured_content.unwrap()["guards"][0];
assert_eq!(g["code"], "approval_required");
assert_eq!(g["remedy_tool"], "books.approve_entry");
}