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:
1 parent
169b914e1f
commit
cf73d0bda5
5 files changed
+803
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! The result envelope: what every tool result carries besides its own
|
||||
//! fields (plan 4.6).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent never has to guess what to do after a call: every result
|
||||
//! says what follows (`next_steps`), what refused it and which tool
|
||||
//! remedies that (`guards`), what to tell the human (`warnings`),
|
||||
//! and, on a tool that can change the books, where the journal ends
|
||||
//! now (`head`).
|
||||
//! SOURCE: plan/served.md 4.6 and P5 step 1; DECISION (P5).
|
||||
//! INVARIANTS:
|
||||
//! 1. The envelope is applied in one place (`apply`, called by
|
||||
//! `call_tool`), so no tool can forget it.
|
||||
//! 2. Every result of a tool that is not read-only has a `head` (the
|
||||
//! journal after the call's last write) and at least one next step.
|
||||
//! 3. A tool's own fields are never overwritten: a payload that already
|
||||
//! has `next_steps`, `guards`, `warnings` or `head` keeps it.
|
||||
//! 4. A refused call has no head and no steps; a refusal that is a
|
||||
//! guard (guards.rs) carries it as `structuredContent.guards`.
|
||||
//! 5. Next steps from the books' attention list are the viewer's own
|
||||
//! items (ledger_package::attention), filtered to the subject the
|
||||
//! call touched.
|
||||
//! ```
|
||||
|
||||
use super::BooksServer;
|
||||
use super::guards;
|
||||
use ledger_package::{Integrity, Severity, Target};
|
||||
use rmcp::model::{CallToolResponse, ContentBlock};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// One thing to do next.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Step {
|
||||
/// The tool to call.
|
||||
pub tool: Option<String>,
|
||||
/// Or the prompt to run.
|
||||
pub prompt: Option<String>,
|
||||
/// Or a command for the human's computer (a download).
|
||||
pub command: Option<String>,
|
||||
/// Its arguments.
|
||||
pub args: Option<Value>,
|
||||
/// Why.
|
||||
pub why: String,
|
||||
/// Whether the work is not done without it.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
pub(crate) fn tool(tool: &str, args: Option<Value>, why: &str, required: bool) -> Step {
|
||||
Step {
|
||||
tool: Some(tool.into()),
|
||||
prompt: None,
|
||||
command: None,
|
||||
args,
|
||||
why: why.into(),
|
||||
required,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json(&self) -> Value {
|
||||
let mut o = Map::new();
|
||||
if let Some(t) = &self.tool {
|
||||
o.insert("tool".into(), json!(t));
|
||||
}
|
||||
if let Some(p) = &self.prompt {
|
||||
o.insert("prompt".into(), json!(p));
|
||||
}
|
||||
if let Some(c) = &self.command {
|
||||
o.insert("command".into(), json!(c));
|
||||
}
|
||||
if let Some(a) = &self.args {
|
||||
o.insert("args".into(), a.clone());
|
||||
}
|
||||
o.insert("why".into(), json!(self.why));
|
||||
o.insert("required".into(), json!(self.required));
|
||||
Value::Object(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tools that are not read-only: their results carry a `head`.
|
||||
pub(crate) fn mutating_tools() -> &'static HashSet<String> {
|
||||
static SET: OnceLock<HashSet<String>> = OnceLock::new();
|
||||
SET.get_or_init(|| {
|
||||
BooksServer::tool_router()
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!t.annotations
|
||||
.as_ref()
|
||||
.and_then(|a| a.read_only_hint)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|t| t.name.to_string())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The steps a tool's own result implies (no books needed): the confirm
|
||||
/// after a preview, and what follows a particular act.
|
||||
pub(crate) fn tool_steps(tool: &str, args: Option<&Map<String, Value>>, result: &Value) -> Vec<Step> {
|
||||
let mut steps = Vec::new();
|
||||
let confirmed = args
|
||||
.and_then(|a| a.get("confirmed"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let entry = result
|
||||
.get("entry_id")
|
||||
.or_else(|| args.and_then(|a| a.get("entry_id")))
|
||||
.and_then(Value::as_u64);
|
||||
let with_entry = |id: u64| Some(json!({ "entry_id": id }));
|
||||
// A preview: what is missing first, then the human confirms, then the
|
||||
// same call with the digest.
|
||||
if let (false, Some(preview), Some(args)) = (
|
||||
confirmed,
|
||||
result.get("preview").and_then(Value::as_str),
|
||||
args,
|
||||
) {
|
||||
if tool == "books.post_entry"
|
||||
&& result.get("receipt_missing").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(id) = entry
|
||||
{
|
||||
steps.push(Step::tool(
|
||||
"books.attach_receipt",
|
||||
with_entry(id),
|
||||
"This entry needs a receipt before it posts: attach the file (upload it first).",
|
||||
true,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.set_draft_state",
|
||||
Some(json!({ "entry_id": id, "state": "awaiting_evidence" })),
|
||||
"Or leave the draft waiting, with a note saying what for, until the receipt arrives.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
let mut confirm = args.clone();
|
||||
confirm.insert("confirmed".into(), json!(true));
|
||||
confirm.insert("preview".into(), json!(preview));
|
||||
steps.push(Step::tool(
|
||||
tool,
|
||||
Some(Value::Object(confirm)),
|
||||
"Show this preview to the human; only when they agree, make the same call with confirmed true and this preview.",
|
||||
true,
|
||||
));
|
||||
return steps;
|
||||
}
|
||||
match tool {
|
||||
"books.set_setting" | "books.add_account" => {
|
||||
steps.push(Step::tool(
|
||||
"books.add_account",
|
||||
None,
|
||||
"Add the accounts the human's chart needs, one at a time.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.draft_entry",
|
||||
None,
|
||||
"Or record a transaction: draft it, add its lines, preview it, then post.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
"books.draft_entry" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.add_line",
|
||||
with_entry(id),
|
||||
"Add the draft's lines: each is an account, a side and an amount; debits must equal credits.",
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.add_line" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.add_line",
|
||||
with_entry(id),
|
||||
"Add the next line of the draft.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.post_entry",
|
||||
with_entry(id),
|
||||
"When the lines balance, preview the post for the human.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.set_draft_state" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.attach_receipt",
|
||||
with_entry(id),
|
||||
"When the document arrives, upload and attach it to this draft.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.attach_receipt" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.post_entry",
|
||||
with_entry(id),
|
||||
"The evidence is attached: preview the post again for the human.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.post_entry" | "books.reverse_entry" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.show",
|
||||
with_entry(id),
|
||||
"Show the human the entry and what it moved.",
|
||||
false,
|
||||
));
|
||||
if tool == "books.post_entry" {
|
||||
steps.push(Step::tool(
|
||||
"books.reverse_entry",
|
||||
with_entry(id),
|
||||
"Only if this was posted in error: reverse it (posted entries are never edited).",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"books.draft_from_template" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.post_entry",
|
||||
with_entry(id),
|
||||
"Preview the draft for the human, who then confirms it.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.record_statement" => {
|
||||
steps.push(Step::tool(
|
||||
"books.mark_cleared",
|
||||
None,
|
||||
"Mark each transaction the statement shows as cleared.",
|
||||
true,
|
||||
));
|
||||
let (account, end, close, attachment) = (
|
||||
result.get("account_code"),
|
||||
result.get("period_end"),
|
||||
result.get("closing_balance"),
|
||||
result.get("attachment_id"),
|
||||
);
|
||||
if let (Some(account), Some(end), Some(close), Some(attachment)) =
|
||||
(account, end, close, attachment)
|
||||
{
|
||||
steps.push(Step::tool(
|
||||
"books.reconcile",
|
||||
Some(json!({
|
||||
"account": account,
|
||||
"statement_date": end,
|
||||
"statement_balance": close,
|
||||
"statement_attachment": attachment,
|
||||
})),
|
||||
"When every line is marked, preview the reconciliation against the statement.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.mark_cleared" => steps.push(Step::tool(
|
||||
"books.reconcile",
|
||||
None,
|
||||
"Mark the rest, then preview the reconciliation against the statement.",
|
||||
false,
|
||||
)),
|
||||
"books.reconcile" => steps.push(Step::tool(
|
||||
"books.reconciliations",
|
||||
None,
|
||||
"See the reconciliation among the account's others.",
|
||||
false,
|
||||
)),
|
||||
"books.list_periods" => {
|
||||
for p in result
|
||||
.get("periods")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|p| p.get("status").and_then(Value::as_str) == Some("open"))
|
||||
.take(3)
|
||||
{
|
||||
if let Some(id) = p.get("id").and_then(Value::as_u64) {
|
||||
steps.push(Step::tool(
|
||||
"books.close_period",
|
||||
Some(json!({ "period_id": id })),
|
||||
"Once everything in this period is recorded and reconciled, preview its close.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"books.close_period" => steps.push(Step::tool(
|
||||
"books.verify",
|
||||
None,
|
||||
"Verify the books after a close: the chain, the replay and every stored file.",
|
||||
false,
|
||||
)),
|
||||
"books.export_cpa_package" => {
|
||||
if let Some(cmd) = result.pointer("/file/download").and_then(Value::as_str) {
|
||||
steps.push(Step {
|
||||
tool: None,
|
||||
prompt: None,
|
||||
command: Some(cmd.to_string()),
|
||||
args: None,
|
||||
why: "Run this to save the package in a folder you name, within 15 minutes."
|
||||
.into(),
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// The attention items about the subject of the call, as steps.
|
||||
fn attention_steps(server: &BooksServer, subject: Subject, store: &ledger_store::Store) -> Vec<Step> {
|
||||
let cfg = server.settings();
|
||||
let settings = ledger_package::Settings {
|
||||
company: &cfg.company,
|
||||
cash_accounts: &cfg.roles.cash,
|
||||
financing_accounts: &cfg.roles.financing,
|
||||
investing_accounts: &cfg.roles.investing,
|
||||
};
|
||||
let today = ledger_core::settings::today_in(cfg.timezone.as_deref());
|
||||
let items = ledger_package::find_with(store, today, &settings, &Integrity::default());
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|a| match (subject, a.target) {
|
||||
(Subject::Entry(s), Target::Entry(t)) => s == t,
|
||||
(Subject::Invoice(s), Target::Invoice(t)) => s == t,
|
||||
_ => false,
|
||||
})
|
||||
.filter_map(|a| {
|
||||
let (tool, args) = a.tool?;
|
||||
let args = if args.is_null() {
|
||||
match (a.target, tool.as_str()) {
|
||||
(
|
||||
Target::Entry(id),
|
||||
"books.attach_receipt" | "books.set_entry_party" | "books.set_entry_settles",
|
||||
) => Some(json!({ "entry_id": id })),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
Some(args)
|
||||
};
|
||||
let title: String = a.title.chars().take(160).collect();
|
||||
Some(Step::tool(&tool, args, &title, a.severity == Severity::Problem))
|
||||
})
|
||||
.take(4)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a call touched, for the attention filter.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Subject {
|
||||
Entry(u64),
|
||||
Invoice(u64),
|
||||
}
|
||||
|
||||
fn subject_of(args: Option<&Map<String, Value>>, result: &Value) -> Option<Subject> {
|
||||
let pick = |k: &str| {
|
||||
result
|
||||
.get(k)
|
||||
.or_else(|| args.and_then(|a| a.get(k)))
|
||||
.and_then(Value::as_u64)
|
||||
};
|
||||
pick("entry_id")
|
||||
.map(Subject::Entry)
|
||||
.or_else(|| pick("invoice_id").map(Subject::Invoice))
|
||||
}
|
||||
|
||||
/// The fallback of a tool that changes things and has nothing particular
|
||||
/// to follow it.
|
||||
fn digest_step() -> Step {
|
||||
Step::tool(
|
||||
"books.digest",
|
||||
None,
|
||||
"See what changed and offer it to the human.",
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the envelope on a tool's answer (INVARIANT 1). `wrote` is the head
|
||||
/// after the call's last write, when it wrote.
|
||||
pub(crate) fn apply(
|
||||
server: &BooksServer,
|
||||
tool: &str,
|
||||
args: Option<&Map<String, Value>>,
|
||||
out: Result<CallToolResponse, rmcp::ErrorData>,
|
||||
) -> Result<CallToolResponse, rmcp::ErrorData> {
|
||||
let Ok(CallToolResponse::Complete(mut r)) = out else {
|
||||
return out;
|
||||
};
|
||||
if r.is_error == Some(true) {
|
||||
// INVARIANT 4.
|
||||
let text = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
ContentBlock::Text(t) => Some(t.text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
let guards = guards::from_refusal(tool, text);
|
||||
if !guards.is_empty() && r.structured_content.is_none() {
|
||||
r.structured_content = Some(json!({ "guards": guards }));
|
||||
}
|
||||
return Ok(CallToolResponse::Complete(r));
|
||||
}
|
||||
let Some(Value::Object(mut body)) = r.structured_content.take() else {
|
||||
return Ok(CallToolResponse::Complete(r));
|
||||
};
|
||||
let before = Value::Object(body.clone());
|
||||
let mutating = mutating_tools().contains(tool);
|
||||
let result = Value::Object(body.clone());
|
||||
let mut steps = tool_steps(tool, args, &result);
|
||||
let head = if mutating {
|
||||
server.lock().ok().map(|store| {
|
||||
let (seq, hash) = store.head();
|
||||
if let Some(subject) = subject_of(args, &result)
|
||||
&& steps.iter().all(|s| !s.required)
|
||||
{
|
||||
steps.extend(attention_steps(server, subject, &store));
|
||||
}
|
||||
json!({ "seq": seq, "hash": hash })
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if mutating && steps.is_empty() {
|
||||
steps.push(digest_step());
|
||||
}
|
||||
if !body.contains_key("next_steps") {
|
||||
body.insert(
|
||||
"next_steps".into(),
|
||||
Value::Array(steps.iter().map(Step::to_json).collect()),
|
||||
);
|
||||
}
|
||||
body.entry("guards").or_insert_with(|| json!([]));
|
||||
body.entry("warnings").or_insert_with(|| json!([]));
|
||||
if let Some(head) = head {
|
||||
body.entry("head").or_insert(head);
|
||||
}
|
||||
let after = Value::Object(body);
|
||||
// The same JSON in the text block (server.rs INVARIANT 1).
|
||||
for c in &mut r.content {
|
||||
if let ContentBlock::Text(t) = c
|
||||
&& serde_json::from_str::<Value>(&t.text).is_ok_and(|v| v == before)
|
||||
{
|
||||
t.text = after.to_string();
|
||||
}
|
||||
}
|
||||
r.structured_content = Some(after);
|
||||
Ok(CallToolResponse::Complete(r))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_preview_gets_its_confirm_step() {
|
||||
let args = json!({"entry_id": 3}).as_object().cloned().unwrap();
|
||||
let steps = tool_steps(
|
||||
"books.post_entry",
|
||||
Some(&args),
|
||||
&json!({"entry_id": 3, "preview": "abc", "status": "preview"}),
|
||||
);
|
||||
assert_eq!(steps.len(), 1);
|
||||
let a = steps[0].args.as_ref().unwrap();
|
||||
assert_eq!(a["confirmed"], true);
|
||||
assert_eq!(a["preview"], "abc");
|
||||
assert!(steps[0].required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_step_a_tool_names_exists() {
|
||||
let names: HashSet<String> = BooksServer::tool_router()
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.map(|t| t.name.to_string())
|
||||
.collect();
|
||||
let result = json!({"entry_id": 1, "file": {"download": "x"}});
|
||||
for tool in mutating_tools() {
|
||||
for s in tool_steps(tool, Some(&Map::new()), &result)
|
||||
.into_iter()
|
||||
.chain([digest_step()])
|
||||
{
|
||||
if let Some(t) = s.tool {
|
||||
assert!(names.contains(&t), "{tool}: step names {t}, which is no tool");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1391,6 +1391,10 @@ enum ShownDates {
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[path = "guards.rs"]
|
||||
mod guards;
|
||||
#[path = "next_steps.rs"]
|
||||
mod next_steps;
|
||||
#[path = "tools/access.rs"]
|
||||
mod access_tools;
|
||||
#[path = "tools/backup.rs"]
|
||||
@@ -1704,6 +1708,8 @@ impl ServerHandler for BooksServer {
|
||||
let running_call = call.clone();
|
||||
// Kept for the record of a call the server stopped before running.
|
||||
let request_args = request.arguments.clone();
|
||||
let tool_name = request.name.to_string();
|
||||
let envelope_args = request.arguments.clone();
|
||||
let result = run_blocking(&self.store, move || {
|
||||
Box::pin(async move {
|
||||
WROTE.with(|w| w.borrow_mut().take());
|
||||
@@ -1740,6 +1746,8 @@ impl ServerHandler for BooksServer {
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
};
|
||||
// The result envelope (next_steps.rs INVARIANT 1).
|
||||
let out = next_steps::apply(&server, &tool_name, envelope_args.as_ref(), out);
|
||||
let mut c = running_call;
|
||||
c.digested(digest);
|
||||
let recorded = c.record_blocking(outcome_of(&out), wrote);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user