Skip to content

update_goal_plan tool schema leaks raw zod internals; rejected by providers with strict tool-schema validation (e.g. Mistral mistral-large-4) #69

Description

@baldassarreFe

Summary

Every OpenCode session that includes the goal tools fails on providers that strictly validate tool JSON Schemas, because the V2 update_goal_plan registration passes raw zod schema objects as the tool input. The host serializes them as-is, producing invalid JSON Schema, and the provider rejects the whole request with 400 Invalid tool schema.

Environment

  • OpenCode v2.0.24, @prevalentware/opencode-goal-plugin 0.1.59
  • Provider: Mistral, model mistral-large-4 (strict tool-schema validation)
  • Other goal tools are unaffected; only update_goal_plan leaks internals

What happens

goalToolsV2 builds the tool input with raw zod objects:

const planToolArgs = {
  goal_id: z.string().min(1),
  expected_revision: z.number().int().nonnegative(),
  plan: GoalPlanInputSchema,
  reason: z.string().trim().min(1).max(2000),
  revisit_evidence: z.string().trim().min(1).max(2000).optional(),
}
// ...
input: v2ObjectSchema(planToolArgs),   // <- properties are zod schemas, not JSON Schema

Captured from the actual provider request (GET on a local logging proxy), the schema OpenCode sends looks like this:

{
  "type": "object",
  "properties": {
    "goal_id": {
      "def": { "type": "string", "checks": [{}] },
      "type": "string",
      "format": null,
      "minLength": 1,
      "maxLength": null
    },
    "expected_revision": {
      "def": { "type": "number", "checks": [ { "def": { "check": "number_format", "format": "safeint" }, ... } ] },
      "type": "number",
      "isFinite": true,
      "minValue": 0,
      "format": "safeint"
    },
    "plan": { "def": { "type": "object", "shape": { ... } } }
  },
  "required": [],
  "additionalProperties": false
}

Invalid keywords include def, checks, shape, optional, isFinite, minValue, maxValue, isInt, and null values for keywords that must be integers/strings ("maxLength": null, "format": null).

The provider (Python jsonschema-style validation) rejects it with any of:

  • Invalid tool schema: None is not of type 'integer'
  • Invalid tool schema: None is not of type 'string'
  • Invalid tool schema: 'optional' is not valid under any of the given schemas

I verified directly against the API that mistral-large-4 strictly validates tool schemas while zai-glm-5-3 silently accepts the same invalid schema — which is why the bug only surfaces on specific models. Since the plugin registers the goal tools into every session (including subagents), any session on a strictly-validating model dies before the first token.

Suggested fix

Convert PlanToolSchema to JSON Schema once via z.toJSONSchema and use that as the V2 tool input (the V2 plugin API takes plain JSON Schema, as the other goal tools already do). Runtime validation is unaffected because planFromTool parses through PlanToolSchema itself.

One extra caution from testing: hand out a deep copy per registration rather than the shared module-level object. Bun's expect().toMatchObject() mutates the object it receives (it emptied properties and even unfroze a frozen object in my repro), and any host that mutates a received tool input would otherwise corrupt the schema for every other registration.

I have a PR ready with the fix + regression tests.

Activity

  1. TimurH commented on Oct 9, 2026

    @TimurH

    Another reproduction, for anyone searching by error string: opencode 2.0.25 (the copy bundled in OpenChamber 2.2.0) with 0.1.59 fails every message on Google Vertex.

    • Gemini on Vertex: unrecognized type 'optional' at properties.revisit_evidence
    • Claude on Vertex and on Bedrock: tools.N.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12

    opencode 1.18.34 works with 0.1.59, since it registers tools through the V1 path. Pinning @prevalentware/opencode-goal-plugin@0.1.58 works on 2.0.25 with both providers until #68 or #70 ships. Remember to restart the background opencode serve --service after changing the pin, or the old plugin stays loaded.

  2. nixaut-codelabs commented on Oct 9, 2026

    @nixaut-codelabs

    Independent confirmation on a third provider family, plus a verified patch.

    Third reproduction path

    muse-spark-1.3-contributor reached through an OpenAI-compatible router whose upstream node is a Responses API endpoint (strict tool-schema validation). The request is rejected before the first token:

    [400] Invalid JSON schema: "optional" is not valid under any of the schemas listed in the 'anyOf' keyword
    

    Router-side call logs show 5 requests rejected with exactly this error — every one of them carrying the update_goal_plan tool, while the other 24 tools in the same requests were clean. Tolerant upstreams (GLM-family nodes on the same router) silently accept the identical payload, which matches the "only some models die" symptom reported above.

    What leaks

    Deep-scanning the serialized update_goal_plan input found 24 occurrences of "type": "optional" used as a type value, plus "type": "default", "type": "nullable", "type": "enum", "type": "never", and Zod internals def, innerType, checks, and [MaxDepth] placeholder strings, e.g.:

    plan.def.shape.phases.def.element.def.shape.tasks.def.element.def.shape.evidence:
      {"def": "[MaxDepth]", "type": "optional"}
    

    Verified fix

    In dist/server.js, goalToolsV2() registers input: v2ObjectSchema(planToolArgs) where every value of planToolArgs is a Zod instance. Replacing that line with a per-property z.toJSONSchema() conversion fixes it:

    input: (() => {
      const properties = {};
      for (const [k, v] of Object.entries(planToolArgs)) {
        const jsonSchema = z2.toJSONSchema(v, { io: "input", unrepresentable: "any" });
        delete jsonSchema.$schema;
        properties[k] = jsonSchema;
      }
      const required = Object.keys(planToolArgs).filter((k) => !planToolArgs[k].isOptional());
      return v2ObjectSchema(properties, required);
    })(),

    Notes for whoever lands the PR:

    • io: "input" maps .optional() props to the plain inner schema and .nullish() to anyOf: [ {...}, { "type": "null" } ] — both valid under strict validators.
    • required must be derived via .isOptional(); after conversion it correctly comes out as ["goal_id", "expected_revision", "plan", "reason"] with revisit_evidence omitted.
    • Because z2.toJSONSchema() returns a fresh object per call and the IIFE runs on each goalToolsV2() invocation, every registration gets its own deep copy — this also addresses the shared-object mutation concern raised earlier in the thread.
    • Verified end-to-end: after the patch, the exact request that returned 400 completes with 200 OK through the same strict upstream.

    Environment: opencode v2.0.26, @prevalentware/opencode-goal-plugin@0.1.59, zod resolved at 4.6.5 inside the plugin cache.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions