fix(mcp): a read says only what follows; a success adds no empty warnings (P5)
Two existing contracts the envelope broke when every result got every field: a resource and its tool say the same (books://settings is books.settings, checked by the_kept_resources_read), and a tool with no warnings has no warnings key (books.add_account, defect 8). So head and next_steps are on every tool that can change the books, next_steps is on a read only when something follows it, guards is on a refusal, and the envelope adds no warnings of its own (a tool's stay as they are). The happy-path spec test now exercises books.journal_history (it fails on a tool it never called), the demo company gets its books.start size check (Done when 3), and the As-built block of P5 is written. 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
8e683942a2
commit
ad1afb478d
6 files changed
+76
-11
No files matched your search
@@ -12,7 +12,10 @@
|
||||
//! 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.
|
||||
//! journal after the call's last write) and at least one next step; a
|
||||
//! read has `next_steps` when something follows it and nothing else (a
|
||||
//! resource and its tool agree). `guards` is on a refusal, and a
|
||||
//! success without warnings has no `warnings` key.
|
||||
//! 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
|
||||
@@ -451,14 +454,18 @@ pub(crate) fn apply(
|
||||
if mutating && steps.is_empty() {
|
||||
steps.push(digest_step());
|
||||
}
|
||||
if !body.contains_key("next_steps") {
|
||||
// A tool that can change the books always says what follows; a read
|
||||
// says it only when something does, so what it says is what a resource
|
||||
// of the same name says (`books://settings` is `books.settings`).
|
||||
// `guards` is on a refusal (above) and `warnings` is the tool's own:
|
||||
// a success that has none says none, as the tools always did.
|
||||
if !body.contains_key("next_steps") && (mutating || !steps.is_empty()) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1564,8 +1564,8 @@ fn every_mutating_result_has_head_and_next_steps() {
|
||||
};
|
||||
let body = r.structured_content.expect("structured");
|
||||
assert!(
|
||||
body["guards"].is_array() && body["warnings"].is_array(),
|
||||
"{name}"
|
||||
body.get("guards").is_none() && body.get("warnings").is_none(),
|
||||
"{name}: a success without them says none"
|
||||
);
|
||||
let ContentBlock::Text(t) = &r.content[0] else {
|
||||
panic!("{name}: no text block");
|
||||
|
||||
Reference in new issue
Block a user