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

  1. Rebuild the capsule with the host-compatible astrid macros so the tool_describe payload matches ToolDescribePayload's schema
  2. Log/print the raw payload bytes and validate them against the expected {"tools":[...]} shape
  3. Check host and capsule library versions — serde field/type mismatches across versions are the usual cause of this serde error
  4. 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

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


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/b6a36b4bdf6a8588. Report an issue: GitHub.