astrid-runtime/astrid · error
tool_describe payload is not valid JSON or has an unexpected
Error message
tool_describe payload is not valid JSON or has an unexpected shape: {e} What it means
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'.
Source
Thrown at crates/astrid-capsule/src/tool_discovery.rs:166
parse_tool_descriptors(&payload).map(Some)
}
/// Parse the `tools` array out of a `tool_describe` descriptor payload
/// (`{ "tools": [ {name, description, input_schema}, ... ], "description": "..." }`).
///
/// Deserializes straight into a typed wrapper rather than walking a generic
/// `serde_json::Value` — no intermediate allocation or clone. A missing
/// `tools` key defaults to empty (a non-tool payload), while a present-but-
/// malformed `tools` array is a hard error.
fn parse_tool_descriptors(payload: &[u8]) -> anyhow::Result<Vec<ToolDescriptor>> {
#[derive(Deserialize)]
struct ToolDescribePayload {
#[serde(default)]
tools: Vec<ToolDescriptor>,
}
let parsed: ToolDescribePayload = serde_json::from_slice(payload).map_err(|e| {
anyhow::anyhow!("tool_describe payload is not valid JSON or has an unexpected shape: {e}")
})?;
Ok(parsed.tools)
}
/// Names of advertised tools that no interceptor route will ever deliver an
/// execute call to.
///
/// A tool is advertised straight from its `#[astrid::tool]` annotation (the
/// describe path bypasses the subscribe ACL), but the dispatcher routes execute
/// calls *solely* from the manifest's `[subscribe]` handlers. So a tool whose
/// `Capsule.toml` is missing (or has a mistyped) `tool.v1.execute.<name>`
/// subscription appears in `tools/list` yet silently never runs — no dispatch,
/// no log, no error. This returns those tool names so the caller can warn.
///
/// Matching uses the SAME [`crate::topic::topic_matches`] the dispatcher uses,
/// so a wildcard subscription (e.g. `tool.v1.execute.*`) correctly counts as a
/// route and is NOT reported. Pure over its inputs.
#[must_use]View on GitHub (pinned to affd8760f4)
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
Example fix
// before (guest emits wrong shape)
return Ok(json!({ "items": tools }).to_string().into_bytes());
// after
return Ok(json!({ "tools": tools }).to_string().into_bytes()); Defensive patterns
Strategy: validation
Validate before calling
// validate payload shape before parsing into typed descriptors
fn payload_is_well_formed(payload: &[u8]) -> bool {
serde_json::from_slice::<serde_json::Value>(payload)
.ok()
.map(|v| v.is_object() && v.get("tools").map_or(true, |t| t.is_array()))
.unwrap_or(false)
} Type guard
fn is_tool_describe_payload(payload: &[u8]) -> bool {
serde_json::from_slice::<serde_json::Value>(payload)
.ok()
.and_then(|v| v.get("tools").cloned())
.map_or(false, |t| t.is_array())
} Try / catch
match parse_tool_descriptors(payload) {
Ok(tools) => tools,
Err(e) if e.to_string().contains("not valid JSON or has an unexpected shape") => {
log::error!("describe payload malformed ({} bytes): {}",
payload.len(), String::from_utf8_lossy(&payload[..payload.len().min(200)]));
Vec::new() // or fall back to the describe fan-out
}
Err(e) => return Err(e),
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: "failed to unmarshal" / json.Unmarshal errors: why parsing a response into a Go struct fails and how to fix it — this error's family across 23 libraries.
Related errors
- tool_describe interceptor failed: {e}
- invalid layout migration record {}: {error}
- decode migration ledger {}: {error}
- migration ledger is not canonical: {}
- reply deserializes
AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09).
Data as JSON: /api/errors/b6a36b4bdf6a8588.
Report an issue: GitHub.