Validate, normalize, repair, and dispatch LLM-generated tool calls; return truncated results safely.
Function Calling
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
LLM tool_use blocks with function name + JSON arguments, tool schemas (TypeBox), conversation transcript.
Schema validation pass/fail, JSON repair strategy, result truncation threshold, context overflow guard.
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
| Decision | What controls it | Why it matters |
|---|---|---|
| Provider format normalization | attempt.tool-call-normalization.ts | Different providers emit tool calls in different shapes; normalization produces a uniform internal format |
| Schema validation pass/fail | TypeBox schemas in schema/typebox.ts | Catches type mismatches, missing required fields, and enum violations before execution |
| JSON repair strategy | attempt.tool-call-argument-repair.ts | Determines whether to fix truncated strings, add missing brackets, or strip trailing commas |
| Parameter validation mode | readStringParam() options: required, trim, allowEmpty | Controls strictness of individual parameter checks; label option improves error messages |
| Access control | OWNER_ONLY_TOOL_ERROR constant | Owner-restricted tools reject calls from non-owner agents with ToolAuthorizationError (403) |
| Result size limit | tool-result-truncation.ts threshold | Prevents massive tool outputs from consuming the entire context window |
| Context overflow protection | tool-result-context-guard.ts | Final 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,
}))};
}
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:
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:
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.
| Function | Purpose | When it runs |
|---|---|---|
repairToolUseResultPairing() | Finds orphaned tool_result messages (no matching tool_use in history) and either re-pairs them or removes them | Before 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
| Scenario | Behavior | Code Location |
|---|---|---|
| LLM emits truncated JSON arguments | Repair module adds missing closing brackets/quotes; re-validates | attempt.tool-call-argument-repair.ts |
| Tool call references non-existent function | Returns ToolInputError with list of available tools | Tool dispatcher |
| Owner-only tool called by non-owner | Returns ToolAuthorizationError (403) with OWNER_ONLY_TOOL_ERROR | tools/common.ts |
| Tool result exceeds truncation limit | Content sliced to limit; truncated: true flag set | tool-result-truncation.ts |
| Result would overflow remaining context | Replaced with size estimate error; LLM can retry with narrower query | tool-result-context-guard.ts |
| Multiple tool calls in single response | Executed in parallel (independent) or sequentially (dependent); results ordered by original call order | Tool dispatcher |
| Orphaned tool_result in transcript | repairToolUseResultPairing() removes or re-pairs during compaction | Transcript repair |
| Malicious content in tool output | stripToolResultDetails() sanitizes before LLM sees it | Transcript repair |
Configuration
| Config Key | Default | Description |
|---|---|---|
tool-result-truncation.maxChars | 50000 | Maximum characters in a single tool result before truncation |
context-guard.reserveTokens | 4096 | Minimum tokens to reserve for the LLM’s next response |
repair.maxAttempts | 2 | Maximum JSON repair attempts before returning error |
validation.coerceTypes | true | Allow TypeBox Value.Cast() type coercion |
dispatch.parallelLimit | 8 | Maximum concurrent tool executions per LLM turn |
transcript.compactInterval | 10 | Run transcript repair every N turns |
Related Topics
See also:
- Tool Use overview — parent module covering all tool-related subsystems
- MCP Bridge — how external MCP tool servers connect to the runtime
- Sandbox Execution — Docker/SSH backends for isolated tool execution
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