Function Calling

Focus Deep-Dive

The function calling protocol — validation, normalization, repair, and execution

When an LLM decides to invoke a tool, it emits a tool_use block containing a function name and JSON arguments. OpenClaw’s function calling pipeline validates that block against TypeBox schemas, normalizes format differences between providers, repairs malformed JSON arguments, dispatches execution, and pipes truncated results back as tool_result messages — all while guarding against context overflow.

📚 What is this & why does it matter?

Function calling is the bridge between an LLM’s reasoning and real-world side effects. Without a robust pipeline, malformed tool calls silently fail, truncated JSON crashes the runtime, and oversized results blow out the context window. This module makes tool invocation reliable across every supported provider.

💡 Real-world analogy

Think of the function calling pipeline like a post office. The LLM writes a letter (tool call) with an address (function name) and contents (arguments). The post office validates the address, fixes smudged ZIP codes (argument repair), checks the package isn’t too heavy (truncation), delivers it, and brings back a signed receipt (tool result). Without the post office, letters go to the wrong address, oversized packages clog the system, and nobody knows what was delivered.

Contents

Overview

Primary role

Validate, normalize, repair, and dispatch LLM-generated tool calls; return truncated results safely.

Main inputs

LLM tool_use blocks with function name + JSON arguments, tool schemas (TypeBox), conversation transcript.

Key decisions

Schema validation pass/fail, JSON repair strategy, result truncation threshold, context overflow guard.

Outputs / side effects

tool_result messages back to the LLM, error objects (ToolInputError, ToolAuthorizationError), repaired transcripts.

flowchart LR
  subgraph LLM["LLM Response"]
    TC["tool_use block"]
  end
  subgraph Pipeline["Function Calling Pipeline"]
    Norm["Normalize Format"]
    Val["Validate Schema"]
    Repair["Repair Arguments"]
    Dispatch["Dispatch Execution"]
    Trunc["Truncate Result"]
    Guard["Context Guard"]
  end
  subgraph Result["Back to LLM"]
    TR["tool_result message"]
  end
  TC --> Norm
  Norm --> Val
  Val -->|Valid| Dispatch
  Val -->|Invalid JSON| Repair
  Repair --> Val
  Dispatch --> Trunc
  Trunc --> Guard
  Guard --> TR

The pipeline is linear in the happy path but includes a repair loop: when schema validation fails due to malformed JSON, the argument repair module attempts to fix common issues (truncated strings, missing brackets, trailing commas) before re-validating. If repair also fails, a ToolInputError is returned to the LLM.

Decision Points

DecisionWhat controls itWhy it matters
Provider format normalizationattempt.tool-call-normalization.tsDifferent providers emit tool calls in different shapes; normalization produces a uniform internal format
Schema validation pass/failTypeBox schemas in schema/typebox.tsCatches type mismatches, missing required fields, and enum violations before execution
JSON repair strategyattempt.tool-call-argument-repair.tsDetermines whether to fix truncated strings, add missing brackets, or strip trailing commas
Parameter validation modereadStringParam() options: required, trim, allowEmptyControls strictness of individual parameter checks; label option improves error messages
Access controlOWNER_ONLY_TOOL_ERROR constantOwner-restricted tools reject calls from non-owner agents with ToolAuthorizationError (403)
Result size limittool-result-truncation.ts thresholdPrevents massive tool outputs from consuming the entire context window
Context overflow protectiontool-result-context-guard.tsFinal safety net: drops or summarizes results that would exceed remaining context budget

How It Works

Tool Call Normalization

Each LLM provider emits tool calls in a slightly different shape. OpenAI uses function_call with a name and arguments string; Anthropic uses tool_use blocks with a structured input object. The normalization layer (attempt.tool-call-normalization.ts) converts all variants into a canonical internal format:

// Internal canonical tool call format after normalization
interface NormalizedToolCall {
  id: string;                    // Unique call ID (provider-generated)
  name: string;                  // Function name (e.g., "read_file")
  arguments: Record<string, unknown>; // Parsed JSON arguments
  raw: unknown;                  // Original provider-specific payload
}

// Normalization handles:
// 1. OpenAI function_call → NormalizedToolCall
// 2. Anthropic tool_use  → NormalizedToolCall
// 3. String arguments    → parsed JSON object
// 4. Missing IDs         → generated UUID fallback
function normalizeToolCall(
  providerBlock: unknown,
  provider: "openai" | "anthropic" | "google"
): NormalizedToolCall {
  // Provider-specific extraction logic...
  const parsed = typeof raw.arguments === "string"
    ? JSON.parse(raw.arguments)
    : raw.arguments;
  return { id: raw.id ?? crypto.randomUUID(), name: raw.name, arguments: parsed, raw };
}

Schema Validation with TypeBox

Every tool registered in the agent runtime declares its parameter schema using TypeBox. After normalization, the pipeline validates the tool call arguments against the declared schema:

// From schema/typebox.ts — tool schema definition
import { Type, Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

const ReadFileSchema = Type.Object({
  path: Type.String({ description: "Absolute path to the file" }),
  encoding: Type.Optional(Type.Union([
    Type.Literal("utf-8"),
    Type.Literal("base64"),
  ])),
  lineRange: Type.Optional(Type.Tuple([Type.Number(), Type.Number()])),
});

type ReadFileArgs = Static<typeof ReadFileSchema>;

// Validation at call time
function validateToolArgs(schema: TSchema, args: unknown): ValidationResult {
  const errors = [...Value.Errors(schema, args)];
  if (errors.length === 0) return { valid: true, value: Value.Cast(schema, args) };
  return { valid: false, errors: errors.map(e => ({
    path: e.path,
    message: e.message,
    expected: e.schema,
  }))};
}
Key detail: Value.Cast() coerces compatible types (e.g., string "42" → number 42) before returning the validated result. This adds a layer of tolerance beyond strict type checking.

Argument Repair

LLMs sometimes produce malformed JSON in tool call arguments — truncated strings, missing closing brackets, trailing commas after the last property. The repair module (attempt.tool-call-argument-repair.ts) attempts to fix these issues before re-validating:

Repair is best-effort: The repair module handles common JSON corruption patterns but does not attempt to guess missing values. If repair fails, a ToolInputError (status 400) is returned to the LLM, which can then retry with corrected arguments.

Tool Dispatch & the AnyAgentTool Type

Once arguments are validated, the pipeline dispatches execution through the AnyAgentTool wrapper defined in tools/common.ts. This type unifies all tool implementations — built-in tools, MCP tools, and skill-based tools — behind a common interface:

// From tools/common.ts
interface AnyAgentTool {
  name: string;
  description: string;
  schema: TSchema;                       // TypeBox schema
  execute(args: unknown, ctx: ToolContext): Promise<ToolResult>;
  metadata: {
    ownerOnly?: boolean;                 // Requires OWNER_ONLY_TOOL_ERROR check
    category: "read" | "write" | "exec" | "session" | "image";
    sandboxRequired?: boolean;
  };
}

// ToolResult wraps the output with metadata
interface ToolResult {
  content: string | object;
  isError?: boolean;
  errorCode?: number;                    // 400 = ToolInputError, 403 = ToolAuthorizationError
  truncated?: boolean;
}

// Error constants
const OWNER_ONLY_TOOL_ERROR = "This tool is restricted to the workspace owner.";

Parameter Validation Helpers

Individual parameters are validated by utility functions like readStringParam(), which provides fine-grained control over string argument handling:

// Parameter validation utility
function readStringParam(
  args: Record<string, unknown>,
  key: string,
  options?: {
    required?: boolean;      // Throw ToolInputError if missing
    trim?: boolean;          // Trim whitespace (default: true)
    label?: string;          // Human-readable label for error messages
    allowEmpty?: boolean;    // Allow empty string (default: false)
  }
): string | undefined {
  const raw = args[key];
  if (raw === undefined || raw === null) {
    if (options?.required) {
      throw new ToolInputError(
        `Missing required parameter: ${options.label ?? key}`
      );
    }
    return undefined;
  }
  let value = String(raw);
  if (options?.trim !== false) value = value.trim();
  if (!value && !options?.allowEmpty) {
    throw new ToolInputError(
      `Parameter "${options.label ?? key}" must not be empty`
    );
  }
  return value;
}

Tool Call Lifecycle

sequenceDiagram
  participant LLM as LLM
  participant Norm as Normalizer
  participant Schema as Schema Validator
  participant Repair as Arg Repair
  participant Dispatch as Dispatcher
  participant Tool as AnyAgentTool
  participant Trunc as Truncator
  participant Guard as Context Guard
  participant Conv as Conversation

  LLM->>Norm: tool_use block
  Norm->>Schema: NormalizedToolCall
  alt Schema valid
    Schema->>Dispatch: validated args
  else Invalid JSON
    Schema->>Repair: validation errors
    Repair->>Schema: repaired arguments
    alt Repair succeeded
      Schema->>Dispatch: validated args
    else Repair failed
      Schema-->>Conv: ToolInputError (400)
    end
  end
  Dispatch->>Tool: execute(args, ctx)
  alt Owner-only check fails
    Tool-->>Conv: ToolAuthorizationError (403)
  else Execution succeeds
    Tool->>Trunc: raw ToolResult
    Trunc->>Guard: truncated result
    Guard->>Conv: tool_result message
  end

The full lifecycle from LLM emission to tool_result insertion typically takes single-digit milliseconds for built-in tools. Network-dependent tools (HTTP requests, file I/O) add their own latency, but the pipeline overhead is negligible.

Annotated Code Walkthrough

Walk through the complete function calling pipeline from raw LLM output to conversation insertion:

Extract and normalize the tool call from the LLM response. Provider-specific formats are unified into NormalizedToolCall:

// attempt.tool-call-normalization.ts
export function extractToolCalls(
  response: LLMResponse,
  provider: ProviderFamily
): NormalizedToolCall[] {
  const blocks = response.content.filter(b => b.type === "tool_use");
  return blocks.map(block => normalizeToolCall(block, provider));
}

// OpenAI: arguments is a JSON string
// Anthropic: input is already an object
// Google: functionCall.args is an object
function normalizeToolCall(block: unknown, provider: ProviderFamily): NormalizedToolCall {
  switch (provider) {
    case "openai":
      return { id: block.id, name: block.function.name,
               arguments: JSON.parse(block.function.arguments), raw: block };
    case "anthropic":
      return { id: block.id, name: block.name,
               arguments: block.input, raw: block };
    case "google":
      return { id: block.id ?? crypto.randomUUID(), name: block.functionCall.name,
               arguments: block.functionCall.args, raw: block };
  }
}

Validate arguments against the tool’s TypeBox schema. On failure, attempt JSON repair before giving up:

// Core validation + repair loop
async function processToolCall(
  call: NormalizedToolCall,
  tool: AnyAgentTool,
  ctx: ToolContext
): Promise<ToolResult> {
  // First attempt: validate as-is
  let validation = validateToolArgs(tool.schema, call.arguments);

  if (!validation.valid) {
    // Attempt repair of malformed JSON
    const repaired = repairToolCallArguments(call.arguments, validation.errors);
    if (repaired !== null) {
      validation = validateToolArgs(tool.schema, repaired);
      if (validation.valid) call.arguments = repaired;
    }
  }

  if (!validation.valid) {
    return { content: formatValidationErrors(validation.errors),
             isError: true, errorCode: 400 };
  }
  // Dispatch to tool execution
  return await tool.execute(validation.value, ctx);
}

Apply result truncation and context guard before inserting the tool_result back into the conversation:

// tool-result-truncation.ts
function truncateResult(result: ToolResult, maxChars: number): ToolResult {
  const content = typeof result.content === "string"
    ? result.content
    : JSON.stringify(result.content);
  if (content.length <= maxChars) return result;
  return {
    ...result,
    content: content.slice(0, maxChars) + "\n...[truncated]",
    truncated: true,
  };
}

// tool-result-context-guard.ts
function guardContext(
  result: ToolResult,
  remainingTokens: number
): ToolResult {
  const estimatedTokens = Math.ceil(String(result.content).length / 4);
  if (estimatedTokens <= remainingTokens) return result;
  return {
    content: "[Result too large for remaining context — " +
             estimatedTokens + " tokens estimated, " +
             remainingTokens + " available]",
    isError: true,
    truncated: true,
  };
}

Session Transcript Repair

Over long sessions, the conversation transcript can develop structural issues — orphaned tool_result messages without matching tool_use blocks, or untrusted payloads injected into tool results. Two repair functions maintain transcript integrity:

Security note: stripToolResultDetails() removes untrusted payloads from tool results before they are sent back to the LLM. This prevents prompt injection via tool output — e.g., a malicious web page returning LLM instructions in its content.
FunctionPurposeWhen it runs
repairToolUseResultPairing()Finds orphaned tool_result messages (no matching tool_use in history) and either re-pairs them or removes themBefore every LLM request, during transcript compaction
stripToolResultDetails()Removes potentially untrusted content from tool results (HTML, scripts, long base64 blobs)After tool execution, before transcript insertion

Edge Cases & Error Handling

ScenarioBehaviorCode Location
LLM emits truncated JSON argumentsRepair module adds missing closing brackets/quotes; re-validatesattempt.tool-call-argument-repair.ts
Tool call references non-existent functionReturns ToolInputError with list of available toolsTool dispatcher
Owner-only tool called by non-ownerReturns ToolAuthorizationError (403) with OWNER_ONLY_TOOL_ERRORtools/common.ts
Tool result exceeds truncation limitContent sliced to limit; truncated: true flag settool-result-truncation.ts
Result would overflow remaining contextReplaced with size estimate error; LLM can retry with narrower querytool-result-context-guard.ts
Multiple tool calls in single responseExecuted in parallel (independent) or sequentially (dependent); results ordered by original call orderTool dispatcher
Orphaned tool_result in transcriptrepairToolUseResultPairing() removes or re-pairs during compactionTranscript repair
Malicious content in tool outputstripToolResultDetails() sanitizes before LLM sees itTranscript repair

Configuration

Config KeyDefaultDescription
tool-result-truncation.maxChars50000Maximum characters in a single tool result before truncation
context-guard.reserveTokens4096Minimum tokens to reserve for the LLM’s next response
repair.maxAttempts2Maximum JSON repair attempts before returning error
validation.coerceTypestrueAllow TypeBox Value.Cast() type coercion
dispatch.parallelLimit8Maximum concurrent tool executions per LLM turn
transcript.compactInterval10Run transcript repair every N turns

See also:

Directory Map

Source files for function calling
src/agents/tools/
├── common.ts                       ← AnyAgentTool type, ToolResult, error constants
├── schema/
│   └── typebox.ts                  ← TypeBox schema definitions for all tools
├── attempt.tool-call-normalization.ts ← Provider format normalization
├── attempt.tool-call-argument-repair.ts ← Malformed JSON repair
├── tool-result-truncation.ts       ← Result size limiting
├── tool-result-context-guard.ts    ← Context overflow protection
├── transcript/
│   ├── repairToolUseResultPairing.ts ← Orphaned tool_result repair
│   └── stripToolResultDetails.ts   ← Untrusted payload removal
└── params/
    └── readStringParam.ts          ← String parameter validation utility