{"record":{"id":"b6a36b4bdf6a8588","repo":"astrid-runtime/astrid","slug":"tool-describe-payload-is-not-valid-json-or-has-an","errorCode":null,"errorMessage":"tool_describe payload is not valid JSON or has an unexpected shape: {e}","messagePattern":"tool_describe payload is not valid JSON or has an unexpected shape: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-capsule/src/tool_discovery.rs","lineNumber":166,"sourceCode":"\n    parse_tool_descriptors(&payload).map(Some)\n}\n\n/// Parse the `tools` array out of a `tool_describe` descriptor payload\n/// (`{ \"tools\": [ {name, description, input_schema}, ... ], \"description\": \"...\" }`).\n///\n/// Deserializes straight into a typed wrapper rather than walking a generic\n/// `serde_json::Value` — no intermediate allocation or clone. A missing\n/// `tools` key defaults to empty (a non-tool payload), while a present-but-\n/// malformed `tools` array is a hard error.\nfn parse_tool_descriptors(payload: &[u8]) -> anyhow::Result<Vec<ToolDescriptor>> {\n    #[derive(Deserialize)]\n    struct ToolDescribePayload {\n        #[serde(default)]\n        tools: Vec<ToolDescriptor>,\n    }\n    let parsed: ToolDescribePayload = serde_json::from_slice(payload).map_err(|e| {\n        anyhow::anyhow!(\"tool_describe payload is not valid JSON or has an unexpected shape: {e}\")\n    })?;\n    Ok(parsed.tools)\n}\n\n/// Names of advertised tools that no interceptor route will ever deliver an\n/// execute call to.\n///\n/// A tool is advertised straight from its `#[astrid::tool]` annotation (the\n/// describe path bypasses the subscribe ACL), but the dispatcher routes execute\n/// calls *solely* from the manifest's `[subscribe]` handlers. So a tool whose\n/// `Capsule.toml` is missing (or has a mistyped) `tool.v1.execute.<name>`\n/// subscription appears in `tools/list` yet silently never runs — no dispatch,\n/// no log, no error. This returns those tool names so the caller can warn.\n///\n/// Matching uses the SAME [`crate::topic::topic_matches`] the dispatcher uses,\n/// so a wildcard subscription (e.g. `tool.v1.execute.*`) correctly counts as a\n/// route and is NOT reported. Pure over its inputs.\n#[must_use]","sourceCodeStart":148,"sourceCodeEnd":184,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-capsule/src/tool_discovery.rs#L148-L184","documentation":"parse_tool_descriptors fails when the bytes returned by the tool_describe interceptor cannot be deserialized into ToolDescribePayload ({\"tools\": [...]}). Any serde error — invalid JSON, non-object payload, or `tools` not being an array of ToolDescriptor — becomes this error, so a garbage or shape-mismatched describe payload is fatal rather than treated as 'no tools'.","triggerScenarios":"interpret_describe_result -> parse_tool_descriptors is given InterceptResult::Continue/Final bytes whose serde_json::from_slice::<ToolDescribePayload> fails: non-JSON bytes, JSON that is not an object, `tools` present with the wrong type, or a ToolDescriptor field with an incompatible type.","commonSituations":"Capsule's #[astrid::tool] codegen emitting a payload shape from a different schema version than the host expects; a guest hand-rolling the describe JSON with wrong field names/types; binary or debug output accidentally sent on the describe channel; truncated payload from an oversized/failed interception.","solutions":["Rebuild the capsule with the host-compatible astrid macros so the tool_describe payload matches ToolDescribePayload's schema","Log/print the raw payload bytes and validate them against the expected {\"tools\":[...]} shape","Check host and capsule library versions — serde field/type mismatches across versions are the usual cause of this serde error","Ensure the interceptor returns only the JSON payload, with no extra logging or binary framing mixed into the bytes"],"exampleFix":"// before (guest emits wrong shape)\nreturn Ok(json!({ \"items\": tools }).to_string().into_bytes());\n// after\nreturn Ok(json!({ \"tools\": tools }).to_string().into_bytes());","handlingStrategy":"validation","validationCode":"// validate payload shape before parsing into typed descriptors\nfn payload_is_well_formed(payload: &[u8]) -> bool {\n    serde_json::from_slice::<serde_json::Value>(payload)\n        .ok()\n        .map(|v| v.is_object() && v.get(\"tools\").map_or(true, |t| t.is_array()))\n        .unwrap_or(false)\n}","typeGuard":"fn is_tool_describe_payload(payload: &[u8]) -> bool {\n    serde_json::from_slice::<serde_json::Value>(payload)\n        .ok()\n        .and_then(|v| v.get(\"tools\").cloned())\n        .map_or(false, |t| t.is_array())\n}","tryCatchPattern":"match parse_tool_descriptors(payload) {\n    Ok(tools) => tools,\n    Err(e) if e.to_string().contains(\"not valid JSON or has an unexpected shape\") => {\n        log::error!(\"describe payload malformed ({} bytes): {}\",\n            payload.len(), String::from_utf8_lossy(&payload[..payload.len().min(200)]));\n        Vec::new() // or fall back to the describe fan-out\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["Pin host and capsule astrid macro versions so the payload schema matches","Validate interceptor output is pure JSON with no stray log output or binary framing","Add a schema round-trip test: generate describe payload in the guest, parse it with parse_tool_descriptors"],"tags":["rust","json","serde","tool-discovery","schema"],"backgroundTag":"json-unmarshal-failed","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-14T11:17:12.474Z"}