Home / Articles / Bounded Agentic Loops: Reliable TypeScript Patterns for LLM Tool Use

This article is published in English.

Bounded Agentic Loops: Reliable TypeScript Patterns for LLM Tool Use

Learn how to architect TypeScript LLM agents using bounded agentic loops with Zod validation, deterministic tool execution, and error recovery for production-grade reliability.

2150 words

Most developers still treat Large Language Models as glorified search boxes: fire off a prompt, parse whatever string comes back, and hope that the arithmetic, the permission checks, or the formatting rules didn't quietly break somewhere in the middle.

That approach becomes dangerous the moment you plug generative AI into systems where correctness actually matters, such as billing, compliance, or inventory management. Treating probabilistic token generation as your source of truth is an architectural risk, not a minor inconvenience. Even if a current-generation model can usually get simple arithmetic right, you should never let probabilistic text generation serve as the authoritative record for business-critical logic.

The pattern that actually scales is what you might call a bounded agentic loop: instead of asking the model to produce answers directly, you let it act as an orchestrator. It calls deterministic tools, inspects strongly typed results from those tools, and recovers from failures, all within strict operational boundaries.

The Single-Turn Trap vs. Bounded Agentic Loops

1. Logic & Math Execution

  • Single-Turn Prompting: Depends on probabilistic next-token prediction, which introduces real hallucination risk for things like statutory formulas, currency conversions, or transactional rules.
  • Bounded Agentic Loop: Pushes all computation to deterministic backend services, so numerical results stay accurate.

2. Data Validation & System Integrity

  • Single-Turn Prompting: Leans on brittle regular expressions or manual string parsing to pull structured values out of free text.
  • Bounded Agentic Loop: Checks every tool argument against a Zod schema at runtime, before anything reaches your business logic or database layer.

3. Fault Tolerance & Model Recovery

  • Single-Turn Prompting: Either fails without explanation or stops completely the moment the model supplies an invalid argument or leaves out a required field.
  • Bounded Agentic Loop: Feeds validation failures and business-rule errors back into the conversation as tool output, letting the model try again with corrected parameters on its next turn.

4. Concurrency & Parallel Execution

  • Single-Turn Prompting: Stuck with sequential, all-in-one generations that slow down anything beyond trivial requests.
  • Bounded Agentic Loop: Fires off several independent tool calls at once using Promise.all.

Division of Responsibilities: LLM vs. Backend

Building a dependable agent means separating what the model decides from what actually executes:

  • What the LLM Owns: Understanding user intent, choosing the right tool semantically, generating arguments for that tool, and composing the final natural-language response.
  • What the Backend Owns: Validating schemas at runtime, authenticating and authorizing the caller, enforcing business rules, guaranteeing idempotency, performing calculations, applying any side effects, and logging everything for auditing.

The 4-Stage Agent Loop Architecture

A robust agent loop moves through four discrete stages:

  1. Plan: The model reads the user's intent, looks at the tools it has available, and emits a structured tool call.
  2. Validate: The backend intercepts that call and checks it against a Zod schema and the caller's permissions before any logic runs.
  3. Execute: The backend performs the actual deterministic work, whether that's a database read, an external API call, or a compute job, and records the effect under an idempotency key.
  4. Observe: The result of that execution is returned to the model as a structured tool message. The model then decides whether another tool call is needed or whether it can produce the final answer.

Production Implementation in TypeScript

The implementation below can be saved as agent.ts. It covers typed tool contracts, Zod validation at runtime, authorization checks, idempotency protection, and hard limits on execution.

import OpenAI from "openai";
import { z } from "zod";
import { randomUUID } from "node:crypto";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});
// ============================================================================
// 1. Types & Operational Contracts
// ============================================================================
export interface SecurityContext {
  userId: string;
  tenantId: string;
  roles: string[];
}
export type ToolResultStatus =
  | "success"
  | "validation_error"
  | "permission_denied"
  | "business_error"
  | "fatal_error";
export interface ToolResult<T = unknown> {
  status: ToolResultStatus;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: unknown;
  };
  metadata: {
    toolName: string;
    toolCallId: string;
    latencyMs: number;
    idempotencyKey?: string;
  };
}
export interface AgentTool<TInput = unknown, TOutput = unknown> {
  name: string;
  description: string;
  schema: z.ZodType<TInput>;
  openAiDefinition: OpenAI.Chat.Completions.ChatCompletionTool;
  isMutating: boolean;
  requiredPermission?: string;
  handler: (
    input: TInput,
    context: SecurityContext,
    idempotencyKey?: string
  ) => Promise<TOutput>;
}
// Custom Error Classes
class BusinessRuleError extends Error {
  constructor(public readonly code: string, message: string) {
    super(message);
    this.name = "BusinessRuleError";
  }
}
// ============================================================================
// 2. Demonstration Tax Tool (Explicit Jurisdictions, No Silent Fallbacks)
// ============================================================================
const CalculateTaxSchema = z.object({
  subtotal: z.number().positive("Subtotal must be greater than 0"),
  countryCode: z
    .string()
    .length(2, "Country code must be a 2-letter ISO code")
    .toUpperCase(),
});
type CalculateTaxInput = z.infer<typeof CalculateTaxSchema>;
interface TaxResult {
  jurisdiction: string;
  rateApplied: number;
  taxAmount: number;
  grossTotal: number;
}
// Simplified demonstration rates. Production systems should query a versioned tax rules engine.
const DEMO_STATUTORY_RATES: Record<string, number> = {
  UK: 0.20, // 20% Standard VAT
  BD: 0.15, // 15% Standard VAT
};
const calculateTaxTool: AgentTool<CalculateTaxInput, TaxResult> = {
  name: "calculateTax",
  description:
    "Computes statutory tax and gross total for verified jurisdictions (UK, BD). Rejects unsupported regions.",
  schema: CalculateTaxSchema,
  isMutating: false,
  openAiDefinition: {
    type: "function",
    function: {
      name: "calculateTax",
      description:
        "Computes statutory tax for an invoice line. Only supports UK and BD in this demonstration environment.",
      parameters: {
        type: "object",
        properties: {
          subtotal: { type: "number", description: "Net amount before tax" },
          countryCode: { type: "string", description: "2-letter ISO code (e.g. 'UK', 'BD')" },
        },
        required: ["subtotal", "countryCode"],
        additionalProperties: false,
      },
      strict: true,
    },
  },
  handler: async (input: CalculateTaxInput): Promise<TaxResult> => {
    const rate = DEMO_STATUTORY_RATES[input.countryCode];
    if (rate === undefined) {
      throw new BusinessRuleError(
        "UNSUPPORTED_JURISDICTION",
        `Jurisdiction '${input.countryCode}' is not supported. Only UK and BD are configured.`
      );
    }
    const taxAmount = Number((input.subtotal * rate).toFixed(2));
    const grossTotal = Number((input.subtotal + taxAmount).toFixed(2));
    return {
      jurisdiction: input.countryCode,
      rateApplied: rate,
      taxAmount,
      grossTotal,
    };
  },
};
// ============================================================================
// 3. Mutating Side-Effect Tool (With Idempotency & Auth Boundary)
// ============================================================================
const RecordInvoiceSchema = z.object({
  clientName: z.string().min(1, "Client name is required"),
  amount: z.number().positive("Amount must be positive"),
  taxAmount: z.number().nonnegative(),
  currency: z.string().length(3).toUpperCase(),
});
type RecordInvoiceInput = z.infer<typeof RecordInvoiceSchema>;
const processedIdempotencyKeys = new Set<string>();
const recordInvoiceTool: AgentTool<RecordInvoiceInput, { invoiceId: string; status: string }> = {
  name: "recordInvoice",
  description: "Records an invoice in the ledger. Mutating operation requiring 'billing:write' permission.",
  schema: RecordInvoiceSchema,
  isMutating: true,
  requiredPermission: "billing:write",
  openAiDefinition: {
    type: "function",
    function: {
      name: "recordInvoice",
      description: "Persists an invoice into the financial accounting ledger.",
      parameters: {
        type: "object",
        properties: {
          clientName: { type: "string", description: "Customer or business entity name" },
          amount: { type: "number", description: "Gross invoice amount" },
          taxAmount: { type: "number", description: "Computed tax portion" },
          currency: { type: "string", description: "3-letter currency code (e.g. GBP, BDT)" },
        },
        required: ["clientName", "amount", "taxAmount", "currency"],
        additionalProperties: false,
      },
      strict: true,
    },
  },
  handler: async (input, context, idempotencyKey) => {
    if (!idempotencyKey) {
      throw new Error("Fatal: Mutating operations require an idempotency key.");
    }
    if (processedIdempotencyKeys.has(idempotencyKey)) {
      return {
        invoiceId: `inv_cached_${idempotencyKey.slice(0, 8)}`,
        status: "already_processed_idempotent",
      };
    }
    processedIdempotencyKeys.add(idempotencyKey);
    return {
      invoiceId: `inv_${randomUUID().slice(0, 8)}`,
      status: "recorded",
    };
  },
};
// ============================================================================
// 4. Strongly Typed Registry & Dispatcher
// ============================================================================
const toolRegistry = new Map<string, AgentTool<any, any>>([
  [calculateTaxTool.name, calculateTaxTool],
  [recordInvoiceTool.name, recordInvoiceTool],
]);
async function dispatchToolCall(
  toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
  agentRunId: string,
  securityContext: SecurityContext
): Promise<ToolResult> {
  const startTime = Date.now();
  const toolName = toolCall.function.name;
  const tool = toolRegistry.get(toolName);
  const idempotencyKey = tool?.isMutating ? `${agentRunId}:${toolCall.id}` : undefined;
  // 1. Tool Existence Check
  if (!tool) {
    return {
      status: "fatal_error",
      error: { code: "UNKNOWN_TOOL", message: `Tool '${toolName}' does not exist.` },
      metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
    };
  }
  // 2. Authorization Boundary Check
  if (tool.requiredPermission && !securityContext.roles.includes(tool.requiredPermission)) {
    return {
      status: "permission_denied",
      error: {
        code: "UNAUTHORIZED",
        message: `Execution rejected: Caller lacks required permission '${tool.requiredPermission}'.`,
      },
      metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
    };
  }
  // 3. Schema Boundary Check (Zod)
  let parsedArguments: unknown;
  try {
    parsedArguments = JSON.parse(toolCall.function.arguments);
  } catch {
    return {
      status: "validation_error",
      error: { code: "MALFORMED_JSON", message: "Arguments payload was not valid JSON." },
      metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
    };
  }
  const validationResult = tool.schema.safeParse(parsedArguments);
  if (!validationResult.success) {
    return {
      status: "validation_error",
      error: {
        code: "SCHEMA_VALIDATION_FAILED",
        message: "Tool arguments failed schema validation.",
        details: validationResult.error.flatten().fieldErrors,
      },
      metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
    };
  }
  // 4. Execution Boundary (Business logic & side effects)
  try {
    const data = await tool.handler(validationResult.data, securityContext, idempotencyKey);
    return {
      status: "success",
      data,
      metadata: {
        toolName,
        toolCallId: toolCall.id,
        latencyMs: Date.now() - startTime,
        idempotencyKey,
      },
    };
  } catch (error) {
    if (error instanceof BusinessRuleError) {
      return {
        status: "business_error",
        error: { code: error.code, message: error.message },
        metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
      };
    }
    return {
      status: "fatal_error",
      error: {
        code: "INTERNAL_EXECUTION_FAILURE",
        message: error instanceof Error ? error.message : "Unhandled execution crash.",
      },
      metadata: { toolName, toolCallId: toolCall.id, latencyMs: Date.now() - startTime },
    };
  }
}
// ============================================================================
// 5. Bounded Orchestration Loop with Observability & Limits
// ============================================================================
interface AgentRunConfig {
  maxCycles?: number;
  timeoutMs?: number;
  model?: string;
}
async function runReliableAgent(
  userPrompt: string,
  securityContext: SecurityContext,
  config: AgentRunConfig = {}
): Promise<string> {
  const {
    maxCycles = 5,
    timeoutMs = 30_000,
    model = process.env.OPENAI_MODEL || "gpt-4o-mini",
  } = config;
  const agentRunId = `run_${randomUUID()}`;
  const startTime = Date.now();
  const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
    {
      role: "system",
      content:
        "You are an enterprise accounting orchestrator. You do not calculate statutory taxes or book entries directly. You coordinate with deterministic tools. If a tool reports a validation error or business rule failure, analyze the issue and attempt to correct parameters or explain the limitation.",
    },
    { role: "user", content: userPrompt },
  ];
  const toolsPayload = Array.from(toolRegistry.values()).map((t) => t.openAiDefinition);
  let cycle = 0;
  while (cycle < maxCycles) {
    cycle += 1;
    // Guardrail: Wall-clock timeout
    if (Date.now() - startTime > timeoutMs) {
      throw new Error(`Agent run [${agentRunId}] aborted: Exceeded timeout of ${timeoutMs}ms.`);
    }
    const response = await openai.chat.completions.create({
      model,
      messages,
      tools: toolsPayload,
      tool_choice: "auto",
    });
    const choice = response.choices[0];
    const assistantMessage = choice.message;
    messages.push(assistantMessage);
    // Terminal condition: Orchestrator reached final text answer
    if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
      return assistantMessage.content ?? "Agent completed without generating text.";
    }
    // Concurrently dispatch independent tool calls
    const toolPromises = assistantMessage.tool_calls.map(async (toolCall) => {
      const result = await dispatchToolCall(toolCall, agentRunId, securityContext);
      return {
        role: "tool" as const,
        tool_call_id: toolCall.id,
        content: JSON.stringify(result),
      };
    });
    const toolMessages = await Promise.all(toolPromises);
    messages.push(...toolMessages);
  }
  throw new Error(`Agent run [${agentRunId}] halted: Exceeded maximum iterations (${maxCycles}).`);
}
// ============================================================================
// 6. Test Scenario: Concurrency, Unsupported Jurisdictions & Auth
// ============================================================================
async function main() {
  const securityContext: SecurityContext = {
    userId: "usr_9918",
    tenantId: "tenant_uk_01",
    roles: ["billing:write"],
  };
  const prompt =
    "Calculate tax for two invoices: 1500 GBP for a client in the UK, and 500 EUR for a client in Germany (DE). If tax calculation succeeds, record the invoice for the UK client.";
  console.log("Executing Agent Run...\n");
  try {
    const finalAnswer = await runReliableAgent(prompt, securityContext);
    console.log("=== Agent Response ===");
    console.log(finalAnswer);
  } catch (err) {
    console.error("Execution failed:", err);
  }
}
main();

Critical Production Realities

1. Never let the system fail silently into a fallback. In domains like finance or regulated engineering, silently substituting an unrecognized country code with some default rate can create serious compliance problems. In the implementation shown earlier, any jurisdiction the system doesn't explicitly support must raise a typed BusinessRuleError. That error flows back to the orchestrator, which then tells the user precisely what boundary was hit instead of fabricating a plausible-looking number.

2. Protect mutating operations with idempotency keys. Operations that only read data can be retried without risk. But anything that mutates state, posting an invoice, adjusting a ledger balance, or firing a webhook, needs to be gated behind an idempotency key. By deriving that key from ${agentRunId}:${toolCallId}, you ensure that if the model calls the same function twice while trying to recover from an error, the backend recognizes the duplicate and skips reprocessing it.

3. Split errors into categories so the model knows how to react. Not every failure should trigger a retry from the LLM:

  • Validation errors (validation_error): the arguments were malformed. The model can read the field-level details Zod reports and correct its payload on the next attempt.
  • Business errors (business_error): a defined rule was violated, such as an unsupported jurisdiction. The model can either try a different approach or surface the limitation to the user.
  • Permission errors (permission_denied): these are unrecoverable for that particular tool call. Since the model has no way to grant itself additional access, the correct behavior is to stop execution cleanly rather than keep retrying.