Skip to main content

Custom Middleware

Middleware in AgentLIB provides a powerful way to intercept and modify agent behavior at different points in the execution lifecycle. Middleware follows a Koa-style async pipeline pattern where each middleware can run code before and after calling next().

Middleware Scopes

Middleware can hook into different lifecycle events:
  • run:before - Before the agent starts processing
  • run:after - After the agent completes
  • step:before - Before each reasoning step
  • step:after - After each reasoning step
  • tool:before - Before a tool is executed
  • tool:after - After a tool completes

Basic Middleware Structure

import type { Middleware, MiddlewareContext, NextFn } from '@agentlib/core'

const myMiddleware: Middleware = {
  name: 'my-middleware',
  scope: 'run:before', // Optional: specify one or more scopes
  async run(mCtx: MiddlewareContext, next: NextFn) {
    // Code before next middleware/execution
    console.log('Before:', mCtx.scope)
    
    await next() // Continue to next middleware
    
    // Code after execution
    console.log('After:', mCtx.scope)
  }
}

Example 1: Request Validation Middleware

Validate and sanitize user input before processing:
import { createAgent, Middleware } from '@agentlib/core'
import { openai } from '@agentlib/openai'

interface AppData {
  userId: string
  plan: 'free' | 'pro' | 'enterprise'
}

const inputValidationMiddleware: Middleware<AppData> = {
  name: 'input-validator',
  scope: 'run:before',
  async run(mCtx, next) {
    const { ctx } = mCtx
    
    // Check input length based on plan
    const maxLengths = { free: 500, pro: 2000, enterprise: 10000 }
    const maxLength = maxLengths[ctx.data.plan]
    
    if (ctx.input.length > maxLength) {
      throw new Error(
        `Input exceeds ${maxLength} characters for ${ctx.data.plan} plan`
      )
    }
    
    // Sanitize input
    const sanitized = ctx.input.trim()
    if (!sanitized) {
      throw new Error('Input cannot be empty')
    }
    
    console.log(`✓ Input validated for user ${ctx.data.userId}`)
    await next()
  }
}

const agent = createAgent<AppData>({
  name: 'validated-agent',
  data: { userId: 'default', plan: 'free' }
})
  .provider(openai({ apiKey: process.env.OPENAI_API_KEY! }))
  .use(inputValidationMiddleware)

await agent.run({ 
  input: 'Hello!',
  data: { userId: 'user-123', plan: 'pro' }
})

Example 2: Tool Caching Middleware

Cache tool results to avoid redundant API calls:
import type { Middleware } from '@agentlib/core'

interface CacheEntry {
  result: unknown
  timestamp: number
}

class ToolCacheMiddleware implements Middleware {
  name = 'tool-cache'
  scope = ['tool:before', 'tool:after'] as const
  
  private cache = new Map<string, CacheEntry>()
  private ttlMs = 5 * 60 * 1000 // 5 minutes
  
  async run(mCtx, next) {
    const { scope, tool } = mCtx
    
    if (!tool) {
      await next()
      return
    }
    
    const cacheKey = `${tool.name}:${JSON.stringify(tool.args)}`
    
    // Check cache on tool:before
    if (scope === 'tool:before') {
      const cached = this.cache.get(cacheKey)
      
      if (cached && Date.now() - cached.timestamp < this.ttlMs) {
        console.log(`🎯 Cache hit: ${tool.name}`)
        // Skip tool execution by not calling next()
        // Inject cached result
        tool.result = cached.result
        return
      }
      
      console.log(`⚡ Cache miss: ${tool.name}`)
    }
    
    await next()
    
    // Store result on tool:after
    if (scope === 'tool:after' && tool.result !== undefined) {
      this.cache.set(cacheKey, {
        result: tool.result,
        timestamp: Date.now()
      })
    }
  }
}

const agent = createAgent({ name: 'cached-agent' })
  .provider(openai({ apiKey: process.env.OPENAI_API_KEY! }))
  .use(new ToolCacheMiddleware())

Example 3: Performance Monitoring Middleware

Track timing and token usage across executions:
import type { Middleware, MiddlewareContext } from '@agentlib/core'

interface MetricsData {
  runDuration: number
  stepCount: number
  toolCalls: number
  totalTokens: number
}

class MetricsMiddleware implements Middleware {
  name = 'metrics'
  
  private timers = new Map<string, number>()
  private metrics: Partial<MetricsData> = {}
  
  async run(mCtx: MiddlewareContext, next) {
    const { scope, ctx, tool } = mCtx
    const timerKey = `${scope}-${ctx.sessionId}`
    
    switch (scope) {
      case 'run:before':
        this.timers.set(timerKey, Date.now())
        this.metrics = { stepCount: 0, toolCalls: 0, totalTokens: 0 }
        break
        
      case 'run:after': {
        const start = this.timers.get(timerKey.replace(':after', ':before'))
        if (start) {
          this.metrics.runDuration = Date.now() - start
        }
        this.metrics.totalTokens = ctx.state.usage.totalTokens
        
        console.log('📊 Metrics:', this.metrics)
        // Send to analytics service
        await this.sendMetrics(this.metrics as MetricsData)
        break
      }
      
      case 'step:after':
        this.metrics.stepCount = (this.metrics.stepCount || 0) + 1
        break
        
      case 'tool:after':
        if (tool && !tool.result?.error) {
          this.metrics.toolCalls = (this.metrics.toolCalls || 0) + 1
        }
        break
    }
    
    await next()
  }
  
  private async sendMetrics(metrics: MetricsData) {
    // Send to your analytics service
    console.log('Sending metrics to analytics...', metrics)
  }
}

const agent = createAgent({ name: 'monitored-agent' })
  .provider(openai({ apiKey: process.env.OPENAI_API_KEY! }))
  .use(new MetricsMiddleware())

Example 4: Error Handling & Retry Middleware

Automatically retry failed tool calls:
import type { Middleware } from '@agentlib/core'

interface RetryConfig {
  maxRetries: number
  backoffMs: number
}

class RetryMiddleware implements Middleware {
  name = 'retry'
  scope = 'tool:before' as const
  
  constructor(private config: RetryConfig = { maxRetries: 3, backoffMs: 1000 }) {}
  
  async run(mCtx, next) {
    const { tool } = mCtx
    if (!tool) {
      await next()
      return
    }
    
    let lastError: Error | undefined
    
    for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
      try {
        await next()
        return // Success!
      } catch (error) {
        lastError = error as Error
        console.warn(
          `⚠️  Tool ${tool.name} failed (attempt ${attempt}/${this.config.maxRetries}):`,
          error
        )
        
        if (attempt < this.config.maxRetries) {
          const delay = this.config.backoffMs * attempt
          console.log(`   Retrying in ${delay}ms...`)
          await new Promise(resolve => setTimeout(resolve, delay))
        }
      }
    }
    
    throw new Error(
      `Tool ${tool.name} failed after ${this.config.maxRetries} attempts: ${lastError?.message}`
    )
  }
}

const agent = createAgent({ name: 'resilient-agent' })
  .provider(openai({ apiKey: process.env.OPENAI_API_KEY! }))
  .use(new RetryMiddleware({ maxRetries: 3, backoffMs: 500 }))

Combining Multiple Middleware

Middleware runs in registration order:
import { createAgent } from '@agentlib/core'
import { createLogger } from '@agentlib/logger'
import { openai } from '@agentlib/openai'

const agent = createAgent({ name: 'multi-middleware-agent' })
  .provider(openai({ apiKey: process.env.OPENAI_API_KEY! }))
  // 1. Validate input first
  .use(inputValidationMiddleware)
  // 2. Then start metrics tracking
  .use(new MetricsMiddleware())
  // 3. Add caching for tools
  .use(new ToolCacheMiddleware())
  // 4. Add retry logic
  .use(new RetryMiddleware())
  // 5. Finally, log everything
  .use(createLogger({ level: 'debug', timing: true }))

Accessing Execution Context

Middleware has full access to the execution context:
const contextAwareMiddleware: Middleware<AppData> = {
  name: 'context-aware',
  async run(mCtx, next) {
    const { ctx, scope, tool } = mCtx
    
    // Access user data
    console.log('User:', ctx.data.userId)
    
    // Access conversation state
    console.log('Messages:', ctx.state.messages.length)
    console.log('Steps:', ctx.state.steps.length)
    
    // Access tool info (if scope is tool:*)
    if (tool) {
      console.log('Tool:', tool.name, tool.args)
    }
    
    // Emit custom events
    ctx.emit('custom:event', { scope, timestamp: Date.now() })
    
    await next()
  }
}

Best Practices

  1. Always call next() unless you intentionally want to short-circuit execution
  2. Use specific scopes to avoid unnecessary overhead
  3. Handle errors gracefully to prevent middleware from breaking the pipeline
  4. Keep middleware focused - each middleware should have a single responsibility
  5. Use TypeScript generics to maintain type safety with custom data types
  6. Clean up resources after execution (timers, connections, etc.)

Next Steps

Build docs developers (and LLMs) love