From e71f826677ef7bb5fecff7dd82c68c804865546b Mon Sep 17 00:00:00 2001 From: Isaac Corbrey Date: Sun, 23 Aug 2026 09:02:59 -0400 Subject: [PATCH] rose-llm: fix triage_decision_schema rejected by Anthropic's real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit triage_decision_schema() had a top-level oneOf (no_op | notify variants), which Anthropic's Messages API rejects on a tool's input_schema root: '400: input_schema does not support oneOf, allOf, or anyOf at the top level'. Every unit test used RecordingLlmTransport (a canned-response fake), so nothing ever round-tripped the schema through the real endpoint — this hit every single email that reached LLM triage in production (i.e. constantly, since a fresh install starts with zero plugins) and was never caught locally. Flattened to a single object with kind as an enum and summary optional; the 'notify requires summary' constraint moves from the schema into rose_core:: TriageDecision's own required field (serde already enforces it on deserialize). Tightened the system prompt to state the per-kind field requirement explicitly now that the schema can't express it structurally. Added a regression test asserting no top-level schema function emits a root oneOf/allOf/anyOf, so this class of bug fails cargo test next time instead of only surfacing against the live API in production. Verified against the real Anthropic API (not just RecordingLlmTransport): both the no_op and notify branches now return 200 and deserialize cleanly. --- crates/rose-llm/src/client.rs | 3 +- crates/rose-llm/src/schema.rs | 58 ++++++++++++++++++++++------------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/crates/rose-llm/src/client.rs b/crates/rose-llm/src/client.rs index 56c24fa..0559278 100644 --- a/crates/rose-llm/src/client.rs +++ b/crates/rose-llm/src/client.rs @@ -33,7 +33,8 @@ impl AnthropicClient { let system = "Rose is triaging an email that matched no deterministic plugin rule. \ Decide whether this is worth surfacing to the user (notify) or safely ignorable \ (no_op). You have no ability to take any mutating action here — the only two \ - outcomes are no_op and notify."; + outcomes are no_op and notify. Always include reasoning. If notify, you must also \ + include a summary; if no_op, omit summary entirely."; let user = format_email_for_prompt(email); let body = build_tool_request( &self.model, diff --git a/crates/rose-llm/src/schema.rs b/crates/rose-llm/src/schema.rs index 492674a..f253e89 100644 --- a/crates/rose-llm/src/schema.rs +++ b/crates/rose-llm/src/schema.rs @@ -4,27 +4,13 @@ use serde_json::{json, Value}; pub fn triage_decision_schema() -> Value { json!({ "type": "object", - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { "const": "no_op" }, - "reasoning": { "type": "string" } - }, - "required": ["kind", "reasoning"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kind": { "const": "notify" }, - "summary": { "type": "string" }, - "reasoning": { "type": "string" } - }, - "required": ["kind", "summary", "reasoning"], - "additionalProperties": false - } - ] + "properties": { + "kind": { "type": "string", "enum": ["no_op", "notify"] }, + "summary": { "type": "string" }, + "reasoning": { "type": "string" } + }, + "required": ["kind", "reasoning"], + "additionalProperties": false }) } @@ -325,3 +311,33 @@ pub fn summary_schema() -> Value { "additionalProperties": false }) } + +#[cfg(test)] +mod tests { + use super::*; + + // TK: Anthropic's Messages API rejects oneOf/allOf/anyOf at the root of a tool's + // input_schema (400: "input_schema does not support oneOf, allOf, or anyOf at the top + // level") — nested use inside `properties` is fine, only the schema root is restricted. + // triage_decision_schema shipped with a top-level `oneOf` for months and only failed + // against the real API, never the RecordingLlmTransport-backed unit tests; this guards + // every top-level schema against the same class of regression. + fn assert_no_top_level_combinator(name: &str, schema: &Value) { + for combinator in ["oneOf", "allOf", "anyOf"] { + assert!( + schema.get(combinator).is_none(), + "{name} has a top-level `{combinator}` — Anthropic's API rejects this on a \ + tool's input_schema root" + ); + } + } + + #[test] + fn no_top_level_schema_uses_a_root_combinator() { + assert_no_top_level_combinator("triage_decision_schema", &triage_decision_schema()); + assert_no_top_level_combinator("conversation_outcome_schema", &conversation_outcome_schema()); + assert_no_top_level_combinator("plugin_manifest_tool_schema", &plugin_manifest_tool_schema()); + assert_no_top_level_combinator("authored_plugin_schema", &authored_plugin_schema()); + assert_no_top_level_combinator("summary_schema", &summary_schema()); + } +} -- knotmirror 0.0.1