Skip to content

13. Tool Output And Strict OpenAI Schema

This chapter explains how tool results serve the model, frontend debug, and runtime telemetry at the same time. The core rule is: the model sees text, while the runtime keeps structured metadata.

After reading this chapter, you should understand:

  • why internal tool output and model-visible content are separate
  • why recoverable errors enter history but fatal errors stop the run
  • how timeout and abort enter the unified output path
  • why OpenAI strict schema changes optional parameter representation

Background

Real tools raised two practical problems:

  1. What exactly should a tool return internally?
  2. What schema should OpenAI strict function calling receive?

Both problems affect production behavior.

Tool Output Contract

The project introduced:

text
lib/agent-tool-output.ts

The internal output shape is:

ts
type AgentToolOutput =
  | { type: 'success'; contentText: string; details?: unknown; notice?: string; truncated?: boolean }
  | { type: 'respond_to_model'; error: AgentToolError; details?: unknown }
  | { type: 'fatal'; error: AgentToolError; details?: unknown };

Model-Visible Serialization

The model does not receive this envelope.

The model receives plain text:

text
success:
  contentText
  [optional notice]

respond_to_model:
  Error [CODE]: message

fatal:
  no function_call_output; stop the run

This keeps model input readable while preserving structured details for logs, debug UI, and future telemetry.

Timeout And Abort

The runtime converts timeout and abort into recoverable model-visible tool outputs:

text
Error [TIMEOUT]: ...
Error [ABORTED]: ...

That lets the model understand that a tool did not complete.

OpenAI Strict Schema Problem

The first browser run with real tools hit an upstream error:

text
Invalid schema for function 'read': ... 'required' is required ...

OpenAI strict tools require every property to appear in required. Optional fields must be represented by allowing null.

Correct Boundary For The Fix

The fix belongs in:

text
lib/openai-tool-schema.ts

not in the agent-owned tool contract.

The agent tool can say:

text
path required
offset optional
limit optional

The OpenAI adapter compiles it to:

text
required: ['path', 'offset', 'limit']
offset.type = ['number', 'null']
limit.type = ['number', 'null']

The runtime Zod parser accepts null for strict-mode optional fields and normalizes it to undefined.

Git Evidence

Relevant commit:

text
ec40dc3 Add structured tool output contract

The strict schema fix and tests grew out of the first real frontend run after that layer.

Tests

Relevant tests:

text
tests/openai-tool-schema.test.ts
tests/agent-builtins.test.ts
tests/agent-sampling-loop.test.ts

Common Misunderstandings

Misunderstanding 1: Tools Should Send JSON Envelopes To The Model

Models usually benefit from concise text. ok, metadata, duration, and truncation details are useful to runtime and debug surfaces, not always to model context.

Misunderstanding 2: All Errors Should Be Fatal

Recoverable errors should become tool outputs the model can see, such as path not found or validation failure. Fatal errors are for runtime states that cannot continue.

Misunderstanding 3: OpenAI Strict Schema Is A Minor Provider Detail

Strict schema affects how tool parameters are declared. The internal contract can express optional fields, while the OpenAI wire schema may need required + nullable fields.

Chapter Summary

This chapter splits tool output into model-visible text and runtime/debug metadata. It also fixes timeout, abort, error serialization, and OpenAI strict schema boundaries.

Chapter Checkpoint

The strict-schema compilation rule and the runtime's acceptance of null each have a key-free verification path:

bash
npx tsx --test tests/openai-tool-schema.test.ts

Measured output:

text
✔ OpenAI strict tool schema marks every property as required (0.853791ms)
✔ OpenAI strict tool schema represents optional properties with null type (0.106375ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

These two cases pin the fix boundary of this chapter: optional parameters are compiled into required + nullable, not omitted from required. Then verify that the runtime side really accepts strict-mode null:

bash
npx tsx --test --test-name-pattern "strict" tests/agent-builtins.test.ts

The measured output is ✔ read accepts OpenAI strict-mode null optional arguments — both sides of the contract, wire schema and Zod parser, are nailed down by tests.