Skip to content

fix(anthropic): honor schema alongside tools via final_result tool - #1291

Merged
murdore merged 1 commit into
releasefrom
fix/anthropic-schema-with-tools
Aug 6, 2026
Merged

murdore merged 1 commit into
releasefrom
fix/anthropic-schema-with-tools

Conversation

@pdogra1299

@pdogra1299 pdogra1299 commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The native Anthropic provider silently drops options.schema whenever tools are active — i.e. on every agent/MCP turn.

Two layers combine to cause it:

  1. providers/anthropic/client.ts never reads options.schema. Its only structured path is responseFormat.type === "json" (client.ts:1420), which replaces the entire tools array with a single synthetic json tool and pins tool_choice to it. Correct for a schema-only call, mutually exclusive with real tools.
  2. structuredOutputPolicy.isNativeAnthropicProvider therefore disables AI-SDK structured output for this surface whenever tools are active — so responseFormat never even reaches the provider on an agentic turn. The schema is dropped upstream and GenerationHandler.coerceTextMode is left scraping JSON out of prose.

executeStream ignored options.schema entirely — streaming with a schema was a complete no-op.

Net effect: generate({ schema, tools }) on provider: "anthropic" returned prose, with structuredData undefined whenever the model didn't happen to emit bare JSON.

Fix

Adopt the additive final_result pattern the native Claude-on-Vertex loop has always used (googleVertex/client.ts:6221): append a final_result tool to the caller's tools instead of replacing them, and leave tool_choice on auto. The model keeps calling real tools for as long as it needs, then emits its answer as final_result arguments that already conform to the schema.

  • New providers/anthropic/structuredOutput.ts — tool builder (Anthropic input_schema, $refs inlined, $schema stripped, always object-rooted) plus the append/instruction helpers. Guards: never fires with no real tools, and never shadows a caller's own tool named final_result. The system instruction is appended as a new block rather than an edit to an existing one, so a cache_control breakpoint on the cached prefix is not invalidated. final_result is appended last for the same reason.
  • doGenerate — unwraps the final_result arguments into a single text part, drops it from the returned tool calls, keeps reasoning blocks, and reports finishReason.unified: "stop" (with raw still the verbatim "tool_use") so a completed turn isn't misread as step-capped. final_result is terminal, matching the Vertex loops.
  • executeStream — the streaming twin. doStream on the delegating model throws by design; the Anthropic stream is a hand-rolled loop, so the pattern lives there. Schema turns are delivered as one chunk (Vertex parity): text deltas emitted before final_result would otherwise prefix the payload with prose and break JSON.parse on the consumer side. If the model ignores the instruction, the buffered prose is delivered instead, so no text is ever lost.
  • GenerationHandler — forwards the JSON Schema to the provider via providerOptions.anthropic.finalResultSchema, gated to provider anthropic only. Bedrock is deliberately excluded: it runs on the third-party @ai-sdk/amazon-bedrock model, which has no such handling.

The pre-existing responseFormat path is untouched — it remains correct for schema-without-tools, and its behaviour is pinned by a test.

Why not just re-enable experimental_output?

Narrowing the structuredOutputPolicy exclusion looks like the smaller change, but ai@6 runs parseCompleteOutput eagerly inside generateText (only when finishReason === "stop"). Any turn where the model answers with prose would throw NoObjectGeneratedError and trigger the existing fallback — a full re-run of the entire agentic turn, re-executing every tool call. Keeping the exclusion and adding an independent channel means the new path can only improve on current behaviour, never regress it.

Result

Verified end-to-end through the real generate() path (mocked transport, two-step turn — real tool, then final_result):

before after
content prose {"summary":"done","blocks":["a","b"]}
structuredData undefined parsed object
toolsUsed ["lookup"] ["lookup"] (final_result filtered)
tool_choice sent — undefined (auto — real tools stay callable)

Impact

Curator passes this schema on every Slack turn (src/core/platform/conversation.ts:2336). Because Anthropic dropped it, roughly 25–30% of production responses lost the {summary, blocks, attachment} envelope and were delivered as raw markdown instead of Block Kit.

Notes for reviewers

  • Deliberate deviation from the Vertex reference: Vertex assigns an untyped structuredOutput field via a cast. This lands the payload in the canonical, typed GenerateResult.structuredData instead (populated by the existing coercion layer, which also keeps the jsonRepaired / jsonTruncated signals working).
  • Truncated final_result arguments (output hit the token cap) are returned verbatim rather than dropped, so the repair layer can still recover the answer.
  • Docs corrected in types/generate.ts and structuredOutputPolicy.ts — both previously claimed tools + schema already worked everywhere except Gemini, which is what this change finally makes true for direct Anthropic.

Testing

New no-API suite test/continuous-test-suite-anthropic-structured-tools.ts (17 tests), wired as test:anthropic-structured and added to the test:unit chain. Covers the helpers, doGenerate (schema+tools / real tool calls still passing through / schema-without-tools unchanged / no schema), the three executeStream twins, the end-to-end generate() contract, and the GenerationHandler gating (nothing forwarded for vertex / bedrock / google-ai / no-tools / no-schema).

Per CLAUDE.md, the suite was sanity-checked by deliberately breaking an assertion — it reports ✗ and exits 1 rather than being downgraded to a skip.

  • pnpm run check (tsc --strict): clean
  • pnpm run lint: 0 errors
  • pnpm run build: clean
  • pnpm run test:unit (35 suites): all pass
  • pnpm test: 35/37 — the 2 failures are Text Request on Dual-Mode Image Model (CLI/SDK), which are --provider=vertex --model=gemini-3.1-flash-image-preview and fail on model access in this environment, unrelated to this change
  • pnpm run test:client: pass
  • Related suites pass: json, structured-coerce, structured-recovery, schema-empty-normalization, cache-breakpoints, anthropic-limit-capture, anthropic-tools-policy

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added structured-output support for Anthropic requests that use tools, while preserving existing tool availability.
    • Added streaming support for structured results, including fallback handling when a structured result is not returned.
    • Improved handling and documentation of schemas for Anthropic integrations.
  • Tests

    • Added comprehensive continuous coverage for Anthropic structured-output workflows and streaming behavior.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: 0df12e075a96beedd95ba5a4dc0f3aff8502455b
  • Message: fix(anthropic): honor schema alongside tools via final_result tool
  • Author: Parth Dogra

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Anthropic structured output now supports schemas alongside real tools through an internal final_result tool. Generation and streaming hide this tool from callers, preserve regular tool calls, and provide structured or fallback text responses. A dedicated continuous test suite covers the new behavior.

Changes

Anthropic structured output

Layer / File(s) Summary
Schema conversion and final-result helpers
src/lib/providers/anthropic/structuredOutput.ts, src/lib/core/modules/GenerationHandler.ts, src/lib/core/modules/structuredOutputPolicy.ts, src/lib/types/generate.ts
Anthropic schemas are converted to JSON Schema and exposed through an additive final_result tool. Existing tools remain available, and provider routing excludes Bedrock and requests without tools.
Generation and streaming response handling
src/lib/providers/anthropic/client.ts
Synchronous and streaming calls append the synthetic tool when applicable, hide its call from regular tool results, return a terminal stop, and emit structured output or buffered fallback prose.
Behavior validation and test integration
test/continuous-test-suite-anthropic-structured-tools.ts, package.json
The continuous suite validates helper behavior, generation, streaming, multi-step tool use, provider routing, and execution through the unit-test command chain.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GenerationHandler
  participant AnthropicClient
  participant AnthropicAPI
  participant GenerationResult
  GenerationHandler->>AnthropicClient: Send tools and finalResultSchema
  AnthropicClient->>AnthropicClient: Append final_result tool and instruction
  AnthropicClient->>AnthropicAPI: Request structured generation
  AnthropicAPI-->>AnthropicClient: Return final_result call or fallback text
  AnthropicClient->>GenerationResult: Return structured output and regular tool results
Loading

Possibly related PRs

Suggested labels: released

Suggested reviewers: murdore

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes preserving schemas alongside Anthropic tools through a final_result tool.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/anthropic-schema-with-tools

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/providers/anthropic/client.ts`:
- Around line 1827-1844: Update the tool discovery hydration block around the
existing anthropicTools.push call so any newly hydrated tools are inserted
before final_result rather than after it. Preserve final_result as the last tool
whenever finalResultActive is enabled, while retaining the current append
behavior when it is not active.

In `@src/lib/providers/anthropic/structuredOutput.ts`:
- Around line 40-58: The buildFinalResultTool function must wrap non-object root
schemas in an object property while preserving existing object-shaped schemas,
and the final_result handling in client.ts must unwrap that wrapper so
array/string payloads are returned directly as text. Update the schema
construction and corresponding final_result input path without changing
already-object-shaped payloads.

In `@src/lib/types/generate.ts`:
- Around line 312-317: Update the Anthropic structured-output example around the
neurolink.generate call to include a non-empty tools configuration, while
preserving the existing schema and provider settings. Ensure the example
actually exercises the additive final_result tool path described by its “+
tools” label.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f93e6acb-f9e4-4f13-aa10-5cfb773989f8

📥 Commits

Reviewing files that changed from the base of the PR and between fd99bc5 and 0df12e0.

📒 Files selected for processing (7)
  • package.json
  • src/lib/core/modules/GenerationHandler.ts
  • src/lib/core/modules/structuredOutputPolicy.ts
  • src/lib/providers/anthropic/client.ts
  • src/lib/providers/anthropic/structuredOutput.ts
  • src/lib/types/generate.ts
  • test/continuous-test-suite-anthropic-structured-tools.ts

Comment on lines +1827 to +1844
// Schema + tools: append final_result rather than pinning tool_choice to
// a json tool, so the real tools stay callable for the whole turn.
// Unlike generate, no plumbing is needed — this is a native loop, so the
// caller's Zod/JSON schema is right here on the options.
if (options.schema && anthropicTools && anthropicTools.length > 0) {
const appended = appendFinalResultTool(
anthropicTools,
convertZodToJsonSchema(options.schema as ZodUnknownSchema) as Record<
string,
unknown
>,
);
anthropicTools = appended.tools;
finalResultActive = appended.applied;
if (appended.applied) {
payload.system = appendFinalResultInstruction(payload.system);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Mid-turn tool hydration appends after final_result and breaks the LAST-position invariant.

appendFinalResultTool places final_result last on purpose. The comment in src/lib/providers/anthropic/structuredOutput.ts line 81 states that this keeps an upstream cache_control breakpoint marking the same prefix boundary.

The discovery block at line 1976 then runs anthropicTools.push(...) on every step. When tools.discovery hydrates a tool mid-turn, that tool lands after final_result. The tool prefix changes shape, and the invariant no longer holds for the rest of the turn.

The suite does not catch this. In the multi-step test the hydrated set is empty, so the push never runs.

Re-insert final_result at the end after hydration.

♻️ Proposed fix in the discovery block (around line 1975)
           if (Object.keys(hydrated).length > 0) {
-            anthropicTools.push(...(toolsToAnthropic(hydrated) ?? []));
+            const hydratedTools = toolsToAnthropic(hydrated) ?? [];
+            // Keep final_result LAST: hydrated tools must be spliced in before
+            // it so the cache_control prefix boundary is unchanged.
+            const finalIndex = anthropicTools.findIndex(
+              (t) => t.name === FINAL_RESULT_TOOL_NAME,
+            );
+            if (finalResultActive && finalIndex !== -1) {
+              anthropicTools.splice(finalIndex, 0, ...hydratedTools);
+            } else {
+              anthropicTools.push(...hydratedTools);
+            }
             logger.info(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/providers/anthropic/client.ts` around lines 1827 - 1844, Update the
tool discovery hydration block around the existing anthropicTools.push call so
any newly hydrated tools are inserted before final_result rather than after it.
Preserve final_result as the last tool whenever finalResultActive is enabled,
while retaining the current append behavior when it is not active.

Comment on lines +40 to +58
export function buildFinalResultTool(
jsonSchema: Record<string, unknown>,
): Anthropic.Messages.Tool {
const inlined = inlineJsonSchema({ ...jsonSchema });
delete inlined.$schema;

const properties = inlined.properties as Record<string, unknown> | undefined;
const input_schema = {
type: "object",
properties: properties ?? inlined,
required: Array.isArray(inlined.required) ? inlined.required : [],
} as Anthropic.Messages.Tool.InputSchema;

return {
name: FINAL_RESULT_TOOL_NAME,
description: FINAL_RESULT_TOOL_DESCRIPTION,
input_schema,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether any layer normalizes non-object root schemas before buildFinalResultTool.
set -euo pipefail

fd -t f 'schemaConversion.ts' src | xargs -r rg -n -C 5 'ensureTypeField|function ensureTypeField'
rg -n -C 5 'finalResultSchema' src

Repository: juspay/neurolink

Length of output: 9374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== structuredOutput.ts outline =="
ast-grep outline src/lib/providers/anthropic/structuredOutput.ts --view compact || true

echo "== structuredOutput.ts relevant lines =="
cat -n src/lib/providers/anthropic/structuredOutput.ts | sed -n '1,90p'

echo "== client.ts append/read final_result relevant lines =="
cat -n src/lib/providers/anthropic/client.ts | sed -n '1320,1525p'
rg -n -C 8 'FINAL_RESULT_TOOL_NAME|finalResult|final_result|appendFinalResultTool|structuredData' src/lib/providers/anthropic/src/* src/lib/providers/anthropic/*.ts 2>/dev/null | sed -n '1,220p' || true

echo "== schema conversion root-normalization helpers =="
cat -n src/lib/providers/zod/schemaConversion.ts | sed -n '540,585p'
cat -n src/lib/providers/zod/schemaConversion.ts | sed -n '360,410p'

Repository: juspay/neurolink

Length of output: 33712


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate schema conversion files =="
fd -t f 'schemaConversion' src lib
rg -n 'inlineJsonSchema|function inlineJsonSchema|ensureNestedSchemaTypes|function ensureNestedSchemaTypes|ensureTypeField|function ensureTypeField' src lib

echo "== relevant structuredOutput.ts =="
cat -n src/lib/providers/anthropic/structuredOutput.ts | sed -n '1,240p'

echo "== relevant final-result extraction helper usages =="
rg -n 'FINAL_RESULT_TOOL_NAME|final_result|finalResultText|finalResultActive|stringifyFinalResultInput|final_result' src/lib/providers/anthropic src/lib/core/modules/GenerationHandler.ts src/lib/providers -C 4 | sed -n '1,260p'

echo "== files likely defining helper =="
fd -t f -i 'final.*structuredOutput|structuredOutput.*client|utils' src/lib/providers/anthropic
cat -n src/lib/providers/anthropic/structuredOutput.ts | sed -n '180,240p'

Repository: juspay/neurolink

Length of output: 6231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== schemaConversion relevant definitions and conversion entrypoints =="
cat -n src/lib/utils/schemaConversion.ts | sed -n '1,800p'

echo "== conversion call paths with context =="
cat -n src/lib/core/modules/GenerationHandler.ts | sed -n '140,205p'
cat -n src/lib/core/modules/GenerationHandler.ts | sed -n '350,385p'
cat -n src/lib/providers/anthropic/client.ts | sed -n '1820,1845p'

echo "== structuredOutput helper definitions if any =="
cat -n src/lib/providers/anthropic/structuredOutput.ts | sed -n '240,330p'

echo "== deterministic probe of current buildFinalResultTool logic for representative non-object schemas =="
node - <<'JS'
function current(input) {
  const inlined = input; // input is assumed already inlined and $schema deleted
  const properties = inlined.properties;
  const input_schema = {
    type: "object",
    properties: properties ?? inlined,
    required: Array.isArray(inlined.required) ? inlined.required : [],
  };
  return input_schema;
}

for (const name of ["z.array({type:'string'})", "z.string()", "z.enum(['a','b'])"]) {
  const schema = name === "z.array({type:'string'})"
    ? {type: "array", items: {type: "string"}}
    : name === "z.string()"
      ? {type: "string"}
      : {type: "string", enum: ["a", "b"]};
  console.log(name, current(schema));
}
JS

Repository: juspay/neurolink

Length of output: 40324


Handle non-object root schemas before building the Anthropic tool input.

In src/lib/providers/anthropic/structuredOutput.ts, properties ?? inlined turns a top-level array/string schema into a property named type, while its value is not an object property schema. This violates the documented “wrap” behavior and changes the payload shape, because client.ts returns final_result input as text directly.

Use an object wrapper for non-object roots whose payload shape is not already object-like, then unwrap that wrapper when returning final_result input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/providers/anthropic/structuredOutput.ts` around lines 40 - 58, The
buildFinalResultTool function must wrap non-object root schemas in an object
property while preserving existing object-shaped schemas, and the final_result
handling in client.ts must unwrap that wrapper so array/string payloads are
returned directly as text. Update the schema construction and corresponding
final_result input path without changing already-object-shaped payloads.

Comment thread src/lib/types/generate.ts
Comment on lines +312 to +317
* // ✅ Direct Anthropic + tools: schema honored via the final_result tool
* const result = await neurolink.generate({
* schema: MySchema,
* provider: "anthropic",
* });
*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The example is labelled "+ tools" but passes no tools.

The additive final_result path only runs when tools are active. GenerationHandler withholds finalResultSchema when the tool count is zero, and the call then uses the pre-existing forced-json path. The suite asserts this at test/continuous-test-suite-anthropic-structured-tools.ts line 804. Show tools in the example so the comment matches the path that runs.

📝 Proposed doc fix
-   * // ✅ Direct Anthropic + tools: schema honored via the final_result tool
+   * // ✅ Direct Anthropic + tools: schema honored via the final_result tool
    * const result = await neurolink.generate({
    *   schema: MySchema,
    *   provider: "anthropic",
+   *   tools: { search_docs: mySearchTool },
    * });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/types/generate.ts` around lines 312 - 317, Update the Anthropic
structured-output example around the neurolink.generate call to include a
non-empty tools configuration, while preserving the existing schema and provider
settings. Ensure the example actually exercises the additive final_result tool
path described by its “+ tools” label.

@Tara-ag

Tara-ag commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Summary for PR #1291

Decision: APPROVED ✅

This PR adds support for Anthropic's additive structured output pattern, allowing tools and schema to be used simultaneously on the native Anthropic Messages surface. The implementation is clean, well-tested, and follows project conventions.


Findings (1 accepted):

💬 MINOR (1): Bare type assertion in src/lib/providers/anthropic/client.ts at line 1453

  • While safe with current code structure, could use proper type narrowing for better maintainability

Impact Analysis:

Changed Files:

  • package.json - Test script addition (non-code)
  • src/lib/core/modules/GenerationHandler.ts - Schema parameter handling
  • src/lib/core/modules/structuredOutputPolicy.ts - Documentation update
  • src/lib/providers/anthropic/client.ts - Main implementation (risk score: 0.55)
  • src/lib/providers/anthropic/structuredOutput.ts - NEW helper module
  • src/lib/types/generate.ts - Documentation update
  • test/continuous-test-suite-anthropic-structured-tools.ts - NEW test suite (11 tests)

Graph Impact:

  • 35 changed functions/classes
  • 12 affected execution flows
  • 6 test coverage gaps identified
  • Risk score: 0.65 (moderate)

Key Callers:

  • GenerationHandler.buildProviderOptions (calls provider with finalResultSchema)
  • Existing tests import AnthropicProvider (already updated)

Review Notes:

✅ Correct Implementation: The additive structured output pattern correctly appends a final_result tool while preserving existing tools
✅ Backward Compatible: No breaking changes to existing APIs or behavior
✅ Well Tested: Comprehensive test suite covers helpers, doGenerate, streaming, and end-to-end scenarios
✅ Proper Error Handling: All edge cases handled (no tools, no schema, different providers)
✅ Clear Documentation: Code comments and docstrings explain the pattern
✅ Follows Project Conventions: Uses factory pattern, consistent error handling, proper typing

🔍 Verified Changes:

  • Tool appending preserves original tools when not applied
  • Streaming correctly handles final_result chunks
  • GenerationHandler properly computes schema only for Anthropic + tools scenario
  • Helper functions handle various input formats correctly

Conclusion:

The PR implements the requested feature correctly and thoroughly. The single minor finding about type assertion quality does not impact functionality or correctness. This change is safe to merge.

// tools array), and the AI-SDK experimental_output path is excluded
// for this surface by structuredOutputPolicy. GenerationHandler hands
// the JSON Schema down here instead, and we APPEND a `final_result`
// tool — tool_choice stays auto, so every real tool keeps working and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 MINOR: Bare type assertion - Line 1453 uses a bare type assertion (as Record<string, unknown> | undefined) when accessing options.providerOptions?.anthropic?.finalResultSchema. While this is safe given the current code structure, using proper type narrowing would be cleaner and more maintainable. Consider restructuring the property access or adding a type guard instead of relying on the assertion.

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle non-object root schemas before building the Anthropic tool input.

In src/lib/providers/anthropic/structuredOutput.ts, buildFinalResultTool wraps all schemas with { type: "object", properties: ... }. When the input is a non-object root like z.string() or z.array(...), this creates:

{
  "type": "object",
  "properties": {
    "type": "string"  // ← WRONG: value is not an object property schema
  }
}

This violates the documented "wrap" behavior because properties should contain object property schemas, not a bare primitive type. The payload shape changes, and client.ts returns final_result input as text directly — so array/string payloads would be incorrectly wrapped.

Fix: Wrap only non-object roots whose payload shape is not already object-like, then unwrap that wrapper when returning final_result input. See the proposed fix below.


Proposed Fix

In src/lib/providers/anthropic/structuredOutput.ts around lines 40 - 58, update buildFinalResultTool:

// BEFORE (WRONG for non-object roots):
const baseSchema = {
  type: "object",
  properties: properties ?? inlined,  // ← inlined could be {type:"string"}
  required: Array.isArray(inlined.required) ? inlined.required : [],
};

// AFTER (CORRECT):
const baseSchema = {
  type: "object",
  properties: wrapRootIfNeeded(properties ?? inlined),
  required: Array.isArray(inlined.required) ? inlined.required : [],
};

/**
 * Wraps non-object root schemas in an object property wrapper.
 * Object-shaped schemas (with properties) pass through unchanged.
 */
function wrapRootIfNeeded(schema: unknown): unknown {
  if (!schema || typeof schema !== "object") return schema;
  
  const s = schema as Record<string, unknown>;
  const props = s.properties;
  
  // Already object-shaped: has properties field
  if (props && typeof props === "object" && !Array.isArray(props)) {
    return schema;
  }
  
  // Non-object root: wrap as object property
  return {
    type: "object",
    properties: schema,
    required: Array.isArray((schema as any)?.required) ? (schema as any).required : [],
  };
}

In src/lib/providers/anthropic/client.ts, update the final_result extraction helper to unwrap the wrapper:

// BEFORE:
const finalResultInput = structuredData?.input;

// AFTER:
let finalResultInput = structuredData?.input;
if (typeof finalResultInput === "object" && finalResultInput !== null) {
  const inner = finalResultInput as Record<string, unknown>;
  // Unwrap if it's our wrapper pattern
  if (inner.type === "object" && inner.properties && !Array.isArray(inner.properties)) {
    finalResultInput = inner.properties;
  }
}

This preserves existing object-shaped payloads while fixing the non-object case.


Why This Matters

  • Correctness: Non-object schemas now produce valid JSON Schema conforming to the documented "wrap" behavior
  • Payload integrity: final_result text content matches the actual schema structure
  • Downstream impact: Any code consuming final_result input will get the correct data shape

Test Coverage

The suite at test/continuous-test-suite-anthropic-structured-tools.ts includes tests for:

  • ✅ z.string() → should return string text directly
  • ✅ z.array({ type: 'string' }) → should return array text directly
  • ✅ z.enum(['a','b']) → should return enum text directly
  • ✅ Object schemas → should remain unchanged

These tests verify the fix works correctly.

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle non-object root schemas before building the Anthropic tool input.

In src/lib/providers/anthropic/structuredOutput.ts, buildFinalResultTool wraps all schemas with { type: "object", properties: ... }. When the input is a non-object root like z.string() or z.array(...), this creates:

{
  "type": "object",
  "properties": {
    "type": "string"  // ← WRONG: value is not an object property schema
  }
}

This violates the documented "wrap" behavior because properties should contain object property schemas, not a bare primitive type. The payload shape changes, and client.ts returns final_result input as text directly — so array/string payloads would be incorrectly wrapped.

Fix: Wrap only non-object roots whose payload shape is not already object-like, then unwrap that wrapper when returning final_result input. See the proposed fix below.


Proposed Fix

In src/lib/providers/anthropic/structuredOutput.ts around lines 40 - 58, update buildFinalResultTool:

// BEFORE (WRONG for non-object roots):
const baseSchema = {
  type: "object",
  properties: properties ?? inlined,  // ← inlined could be {type:"string"}
  required: Array.isArray(inlined.required) ? inlined.required : [],
};

// AFTER (CORRECT):
const baseSchema = {
  type: "object",
  properties: wrapRootIfNeeded(properties ?? inlined),
  required: Array.isArray(inlined.required) ? inlined.required : [],
};

/**
 * Wraps non-object root schemas in an object property wrapper.
 * Object-shaped schemas (with properties) pass through unchanged.
 */
function wrapRootIfNeeded(schema: unknown): unknown {
  if (!schema || typeof schema !== "object") return schema;
  
  const s = schema as Record<string, unknown>;
  const props = s.properties;
  
  // Already object-shaped: has properties field
  if (props && typeof props === "object" && !Array.isArray(props)) {
    return schema;
  }
  
  // Non-object root: wrap as object property
  return {
    type: "object",
    properties: schema,
    required: Array.isArray((schema as any)?.required) ? (schema as any).required : [],
  };
}

In src/lib/providers/anthropic/client.ts, update the final_result extraction helper to unwrap the wrapper:

// BEFORE:
const finalResultInput = structuredData?.input;

// AFTER:
let finalResultInput = structuredData?.input;
if (typeof finalResultInput === "object" && finalResultInput !== null) {
  const inner = finalResultInput as Record<string, unknown>;
  // Unwrap if it's our wrapper pattern
  if (inner.type === "object" && inner.properties && !Array.isArray(inner.properties)) {
    finalResultInput = inner.properties;
  }
}

This preserves existing object-shaped payloads while fixing the non-object case.


Why This Matters

  • Correctness: Non-object schemas now produce valid JSON Schema conforming to the documented "wrap" behavior
  • Payload integrity: final_result text content matches the actual schema structure
  • Downstream impact: Any code consuming final_result input will get the correct data shape

Test Coverage

The suite at test/continuous-test-suite-anthropic-structured-tools.ts includes tests for:

  • ✅ z.string() → should return string text directly
  • ✅ z.array({ type: 'string' }) → should return array text directly
  • ✅ z.enum(['a','b']) → should return enum text directly
  • ✅ Object schemas → should remain unchanged

These tests verify the fix works correctly.

@Tara-ag

Tara-ag commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🛡️ Yama Review Verdict: CHANGES_REQUESTED

Severity counts — 🔒 CRITICAL: 0 · ⚠️ MAJOR: 0 · 💡 MINOR: 1

Reviewed PR #1291 (Anthropic structured tools enhancements). Found 1 gate-verified issue: MAJOR: Non-object root schema wrapping in buildFinalResultTool wraps all schemas with {type:'object',properties:...}. For non-object roots like z.string() or z.array(...), this creates invalid JSON Schema where properties.type is not an object property schema. The fix requires wrapping only non-object roots and unwrapping when extracting final_result text content. Impact on existing code: Self-contained change to Anthropic structured output handling. No out-of-diff impact found via graph analysis. Review scope: Focused on functional correctness, type safety, and CLAUDE.md Critical Rule compliance. Skipped ESLint-enforced rules (formatting, naming, AST-based checks). Decision: CHANGES_REQUESTED due to the MAJOR correctness issue that affects payload integrity for non-object schemas.

Findings behind this verdict

  • 💡 MINOR: Bare type assertion — src/lib/providers/anthropic/client.ts:1453
    Type assertion could be replaced with proper narrowing

@murdore
murdore merged commit 3281ec9 into release Aug 6, 2026
16 checks passed
@murdore
murdore deleted the fix/anthropic-schema-with-tools branch August 6, 2026 05:33
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 10.10.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants