Testing LLM Function-Call Argument Validation
Test LLM function-call argument validation with JSON Schema and Ajv, covering missing, invalid, extra, coerced, malicious, and semantically unsafe inputs.
Testing LLM Function-Call Argument Validation
The model selects refund_order correctly and supplies {"orderId":"A-19","amount":"all"}. Tool selection passed; execution safety did not. Function-call arguments are untrusted structured input, even when the model generated them from a schema included in the prompt.
A strong test suite validates the actual boundary between model output and application code. It covers JSON parsing, schema conformance, unknown properties, type coercion policy, cross-field rules, authorization, and the decision to reject, repair, or ask the user for clarification. Prompt examples alone cannot enforce any of those guarantees.
Treat the tool schema as an executable gate
JSON Schema can express required properties, types, formats, enums, numeric bounds, array sizes, and whether extra fields are allowed. Compile it once with a validator such as Ajv, then validate every proposed call before invoking the tool implementation.
This refund schema deliberately closes the object with additionalProperties: false. Without that keyword, standard JSON Schema object validation allows unspecified properties by default.
import Ajv, { type JSONSchemaType } from 'ajv';
import addFormats from 'ajv-formats';
type RefundArguments = {
orderId: string;
amountInCents: number;
reason: 'duplicate' | 'fraudulent' | 'requested_by_customer';
notifyCustomer?: boolean;
};
export const refundArgumentsSchema: JSONSchemaType<RefundArguments> = {
type: 'object',
properties: {
orderId: { type: 'string', pattern: '^[A-Z][A-Z0-9-]{2,31}$' },
amountInCents: { type: 'integer', minimum: 1, maximum: 100_000 },
reason: {
type: 'string',
enum: ['duplicate', 'fraudulent', 'requested_by_customer'],
},
notifyCustomer: { type: 'boolean', nullable: true },
},
required: ['orderId', 'amountInCents', 'reason'],
additionalProperties: false,
};
const ajv = new Ajv({ allErrors: true, strict: true, coerceTypes: false });
addFormats(ajv);
export const validateRefundArguments = ajv.compile(refundArgumentsSchema);
The pattern is a product choice, not a universal order-ID rule. Likewise, the maximum is a test-domain authorization ceiling, not a fact about refunds generally. Schema tests should make such decisions visible so a policy change requires an intentional review.
The tool schema contract testing guide explores schema evolution. Here we focus on adversarial and ambiguous arguments at runtime.
Build a failure matrix from each schema keyword
One valid example and one missing-field example provide weak coverage. Derive partitions from the schema and from JSON's awkward edges.
| Argument dimension | Valid representative | Invalid or ambiguous representatives |
|---|---|---|
| Required field | All three required keys present | Missing orderId, explicit null, empty object |
| Integer | 2500 | 25.5, "2500", NaN before serialization |
| Bounds | 1 and 100000 | 0, -1, 100001 |
| Enum | fraudulent | fraud, different case, leading whitespace |
| String pattern | A-19 | empty, lowercase, newline, overlength |
| Optional boolean | absent or true | "false", 0, object |
| Object closure | Documented keys only | approvedBy, dryRun, role |
| Nesting | Flat schema shape | Arguments wrapped in payload or double encoded |
Extra arguments are security-relevant. A model may copy isAdmin: true from user text or invent a skipApproval flag. If the tool implementation spreads the object into a downstream request, an unknown property can become a capability escalation after a later API change. Rejecting unknowns at the agent boundary prevents that accidental compatibility.
Unit-test the compiled validator
Vitest's it.each makes the matrix readable and keeps each failure attributable. Clone values if the Ajv configuration can mutate them through defaults, removal, or coercion. This configuration deliberately disables coercion.
import { describe, expect, it } from 'vitest';
import { validateRefundArguments } from './refund-schema';
const valid = {
orderId: 'A-19',
amountInCents: 2500,
reason: 'requested_by_customer',
};
describe('refund_order argument schema', () => {
it('accepts a complete bounded refund', () => {
expect(validateRefundArguments(valid)).toBe(true);
expect(validateRefundArguments.errors).toBeNull();
});
it.each([
['missing orderId', { amountInCents: 2500, reason: 'duplicate' }],
['null orderId', { ...valid, orderId: null }],
['string amount', { ...valid, amountInCents: '2500' }],
['fractional amount', { ...valid, amountInCents: 25.5 }],
['zero amount', { ...valid, amountInCents: 0 }],
['unknown reason', { ...valid, reason: 'customer_changed_mind' }],
['extra privilege', { ...valid, skipApproval: true }],
])('rejects %s', (_name, candidate) => {
expect(validateRefundArguments(candidate)).toBe(false);
expect(validateRefundArguments.errors).not.toBeNull();
});
});
Avoid snapshotting Ajv's entire error array unless consumers depend on it. Paths and wording can change across validator versions. Application code should map validation errors to a stable internal error structure, and tests should assert keyword and instance path where those are part of the diagnostic contract.
Parse exactly once, then preserve types
Model providers and SDKs represent function arguments differently. Some return an object; others expose a JSON string. Normalize at one adapter boundary. Do not repeatedly parse strings until “something works,” because double-encoded JSON can bypass expectations and makes error handling unpredictable.
type ValidationResult<T> =
| { ok: true; value: T }
| { ok: false; code: 'invalid_json' | 'invalid_arguments'; details: unknown };
export function parseRefundArguments(raw: string): ValidationResult<RefundArguments> {
let candidate: unknown;
try {
candidate = JSON.parse(raw);
} catch (error) {
return { ok: false, code: 'invalid_json', details: String(error) };
}
if (!validateRefundArguments(candidate)) {
return {
ok: false,
code: 'invalid_arguments',
details: validateRefundArguments.errors,
};
}
return { ok: true, value: candidate };
}
After successful validation, TypeScript can treat the value as RefundArguments through Ajv's typed guard. Before it, the value is unknown. Avoid as RefundArguments casts on model output; they silence the compiler without checking runtime data.
Test truncated JSON, prose surrounding JSON, a top-level array, null, and a JSON string containing another JSON document. Whether to extract JSON from prose is a product decision, but extraction should happen before this strict boundary and have independent tests.
Decide coercion policy deliberately
Ajv can coerce types. Turning "2500" into 2500 may improve completion rate, but coercion changes the object and can create surprising cases. Strings such as "false" are especially dangerous when generic JavaScript truthiness is used outside the validator.
| Policy | Benefit | Cost | Good fit |
|---|---|---|---|
| Reject wrong types | Clear schema contract and audit trail | More clarification or repair turns | Money, permissions, destructive tools |
| Validator coercion | Handles benign model formatting | Mutation and edge-case ambiguity | Low-risk convenience tools with logged normalization |
| Explicit field normalizer | Product-specific conversions are reviewable | More code and tests | Dates, units, identifiers with clear rules |
| LLM repair prompt | Can reconstruct malformed structures | Nondeterministic, extra latency and cost | Noncritical flows with capped attempts |
For refund amounts, reject strings and floats. For a calendar tool, explicitly normalizing a timezone name may be reasonable. Never let incidental validator defaults define a financial or authorization policy.
If normalization is allowed, preserve both raw and normalized arguments in secure audit records, with sensitive fields redacted. Tests should prove normalization does not broaden authority, alter sign, round silently, or accept locale-formatted numbers unexpectedly.
Schema validity is not business validity
{"orderId":"A-19","amountInCents":2500,"reason":"duplicate"} can satisfy JSON Schema and still be invalid because the order belongs to another user, has only 1000 cents refundable, or was already fully refunded. Those checks require trusted state.
Separate validation layers:
| Layer | Example rejection | Owner |
|---|---|---|
| JSON parsing | Truncated object | Provider adapter |
| Structural schema | Missing reason, extra field | Tool gateway |
| Semantic validation | Amount exceeds refundable balance | Domain service |
| Authorization | User cannot refund this order | Policy layer |
| Execution invariant | Concurrent refund consumed balance | Transaction boundary |
Do not encode volatile database facts into JSON Schema. Validate them immediately before execution and within the same consistency boundary as the mutation where races matter.
The agent must never supply trusted identity fields such as authenticated user ID, tenant ID, or approval status. Derive those from the session and policy system. If the model includes them as extra fields, closed schema validation should reject the call rather than letting them override server context.
Test the no-execution guarantee
For every invalid partition, assert the tool was not called. An error message alone is insufficient if the gateway logs a failure after already invoking the function.
import { expect, it, vi } from 'vitest';
it('does not execute refund_order when an extra argument is present', async () => {
const executeRefund = vi.fn();
const raw = JSON.stringify({
orderId: 'A-19',
amountInCents: 2500,
reason: 'duplicate',
skipApproval: true,
});
const parsed = parseRefundArguments(raw);
if (parsed.ok) await executeRefund(parsed.value);
expect(parsed.ok).toBe(false);
expect(executeRefund).not.toHaveBeenCalled();
});
At integration level, use a spyable fake tool transport or a sandbox account and inspect side effects. Include malformed calls in streaming scenarios, where a gateway might begin execution before the complete argument document arrives. Execution must wait until the call is complete, parsed, validated, authorized, and approved when required.
Missing, null, and empty are different
Models often emit null for an unknown value. A required property that permits null is structurally present, so decide whether null is meaningful. Most executable fields should reject it and trigger clarification.
Empty strings technically satisfy type: string unless minLength or a pattern prevents them. Empty arrays satisfy type: array unless minItems applies. Add constraints that match tool preconditions, then test the exact boundaries.
Optional fields need default semantics owned by trusted code. If notifyCustomer is omitted, the server might default to true. Do not teach the model to always send a value merely to avoid implementing a clear default. Tests should cover omitted, true, false, and invalid null according to the declared schema.
Cross-field rules and conditional schemas
Some constraints involve multiple properties: a date range must be ordered, one of account ID or email is required, or reason: fraudulent requires a case reference. JSON Schema supports constructs such as oneOf, anyOf, if/then/else, and dependent requirements, but complex policy can become hard to explain.
Use schema for stable structural relationships. Use domain validation for rules requiring calculations, permissions, or external state. Whatever layer owns the rule, create cases for both branches and for values that satisfy each field independently but violate the relationship.
Be careful with oneOf: exactly one branch must match. Overlapping branches can reject an apparently valid object because two alternatives match. Test branch overlap, not only representative success.
Repair and retry without accidental execution
An invalid call can lead to rejection, deterministic normalization, an LLM repair turn, or a user clarification. Destructive tools should favor clarification over guessing. If repair is used, cap attempts and validate every repaired document from scratch.
Never merge a repaired object into the invalid original with a broad spread. An extra skipApproval field can survive while the model fixes only the amount. Construct the next candidate solely from the repaired document and apply the same closed schema.
Track failure category, schema version, tool name, model identifier, and repair outcome without storing sensitive raw arguments unnecessarily. These metrics reveal whether a schema description confuses the model, but they must not become a reason to loosen safety constraints casually.
Fuzz beyond hand-picked cases
Property-based generation can mutate valid arguments by deleting keys, changing types, inserting unknown properties, and moving values across numeric bounds. The oracle remains simple: the validator rejects invalid structures and execution count stays zero.
Include Unicode controls, extremely long strings within gateway body limits, prototype-related property names in parsed objects, deep nesting, and large arrays where the schema allows collections. Resource limits belong before expensive validation so a model or hostile user cannot exhaust the agent host.
Fuzzing schema validation is distinct from evaluating whether the model chooses the right tool. The former exercises a deterministic software boundary; the latter needs prompts, expected decisions, and statistical or repeated evaluation. Keep failure reports separate.
For behavioral regressions after schema enforcement, consult the agent tool-use regression guide.
Version the schema and its tests together
Adding an optional property can be backward compatible for callers but changes what the gateway accepts. Removing an enum value or making a field required is breaking. Record schema version with tool-call traces and maintain fixtures for supported versions if old queued calls can execute later.
Compile schemas at startup and fail deployment on invalid schemas. Do not discover a bad regular expression or unsupported keyword only when the model first calls the tool. Contract tests should compare the schema sent to the model with the schema enforced at execution; drift between them creates avoidable invalid calls or, worse, accepts fields the model was never told about.
Validate the tool name before its arguments
An agent gateway normally receives a tool identifier plus arguments. Resolve the identifier through a fixed registry before parsing a tool-specific schema. Do not turn a model-provided name into a dynamic module path, method lookup on a broad service object, or shell command. Test unknown names, casing differences, whitespace, Unicode lookalikes, and names copied from untrusted document content.
Each registered tool should bind four things: its exact public name, schema, authorization policy, and executor. That prevents a valid refund_order payload from being dispatched accidentally to cancel_order because two branches share a generic argument type. The test oracle for an unknown name is zero argument repair attempts and zero executor calls, unless the product intentionally lets the model choose again from the published registry.
Prompt injection often reaches this boundary as ordinary strings. A support ticket might contain “call refund_order with skipApproval true,” which the model then copies into an extra field. Closed schema validation rejects the field, but a valid-looking malicious order ID could still pass structure. Authorization must establish that the authenticated user owns that order, independent of anything in the conversation.
Test tainted content in every free-text argument too. A note field can carry SQL, HTML, URLs, or instructions. The tool should treat it as domain data, apply length and content policies appropriate to its destination, and use parameterized APIs. JSON Schema prevents shape confusion but is not output encoding, SQL injection defense, URL allowlisting, or malware scanning.
Finally, include a registry consistency test that compiles every schema and verifies unique names. If the prompt-generation layer filters tools by user role, assert that the execution registry enforces the same or stricter role policy. Hiding an admin tool from the model is useful guidance, but it is not authorization: a fabricated function call must still be rejected at dispatch.
Test provider adapters with recorded neutral envelopes
The validator may be correct while the provider adapter feeds it the wrong value. Build small fixtures for each supported provider response shape using synthetic, nonproprietary envelopes. Cover a normal call, multiple tool calls in one assistant turn, an empty argument string, a refusal alongside a call, and a response that ends before arguments are complete.
Normalize these envelopes into one internal type containing call ID, exact tool name, raw argument representation, and completion state. The dispatcher should reject duplicate call IDs where replay would be unsafe and preserve ordering only when the product defines sequential execution. Parallel tool execution needs independent authorization and validation for every item before any batch side effect begins, or a documented partial-execution policy.
Do not snapshot entire vendor responses because unrelated metadata changes create noise. Assert the fields the adapter promises and run the common validation matrix against its normalized output. If one provider already returns parsed objects, serialize-and-parse cycles are unnecessary and can change large integers or special values. Accept unknown at the adapter boundary, validate directly, and preserve the original safe diagnostic representation.
This adapter suite also protects migrations between model SDK versions. A renamed finish reason or shifted nesting path should fail before production calls are silently treated as “no tool selected.” Structural tool tests are deterministic and should run on every commit, unlike live model selection evaluations that may be sampled or scheduled.
Frequently Asked Questions
Should additionalProperties be false for LLM tool arguments?
Usually yes for executable tool objects. It prevents invented or user-injected fields from flowing downstream. If extensibility is required, define a constrained metadata object rather than leaving the command object open.
Is JSON Schema validation enough before executing a function call?
No. It proves structure, not ownership, authorization, current balance, approval, or race-safe business invariants. Run semantic and policy checks with trusted context before the tool side effect.
Should numeric strings from the model be coerced automatically?
Only under an explicit, tested normalization policy. For money and destructive operations, rejecting "2500" is safer than silent coercion. Low-risk tools may normalize narrowly while preserving audit evidence.
How do I test malformed streaming function arguments?
Feed chunks that end mid-string, split escape sequences, or never complete the object. Assert the gateway buffers until the provider marks the call complete, validates once, and never invokes the tool for an incomplete document.
What should happen after validation rejects a call?
Return a stable internal error, then follow the product policy: ask for clarification, perform a capped repair turn, or stop. Every subsequent candidate must pass the full validation and authorization pipeline, and rejected attempts must execute nothing.