ElastiCache Redis: Scratchpads and Tool-Result Cache for Multi-Turn Coding Agents

0 views

Every multi-turn coding agent accumulates junk between messages: partial file diffs, last tool JSON, embedding of the open buffer, a short-lived grant for shell_exec. Stuffing all of that into DynamoDB works until p99 latency and RCU cost make the hot path ugly. ElastiCache for Redis is the right scratchpad: millisecond GETs, TTL that actually means TTL, and Lua/pipelines for idempotent tool-result keys. Use DynamoDB for durable session truth; use Redis for the working set that dies with the turn.

⚡ TL;DR: Put scratchpads and tool-result caches in ElastiCache Redis with short TTLs and idempotency keys. Reuse Redis connections across Lambda invokes (module-level client). Keep durable session + WebSocket connection IDs in DynamoDB — see DynamoDB Streams session expiry hooks and API Gateway WebSockets for multi-turn coding agents. Gate tool availability with AppConfig kill switches.

What belongs in Redis vs DynamoDB

Data Store Why
WebSocket connectionId, tenant, auth subject DynamoDB Durable, streamable on TTL delete
Last N tool results for the current turn Redis Hot, discardable, high QPS
Scratchpad (model “notes”, open files index) Redis TTL 15–60m Evict freely
Idempotency key → tool result Redis SET NX + TTL Fast dedupe under retry
Billing / audit of tool calls DynamoDB / S3 Must survive cache flush
// ❌ Everything in DynamoDB — durable but slow for scratch
await ddb.send(new PutCommand({
  TableName: "sessions",
  Item: {
    pk: `sess#${sessionId}`,
    scratchpad: giantJson, // rewritten every tool hop
    toolResults: [...previous, latest],
  },
}));

✅ Split: DynamoDB holds the session envelope; Redis holds scratch + tool-result cache.

TTL scratchpads and key layout

Design keys so a flush of one session never touches another tenant.

agent:{tenantId}:{sessionId}:scratch          → HASH or STRING, TTL 1800
agent:{tenantId}:{sessionId}:tool:{idemKey}   → STRING (JSON result), TTL 900
agent:{tenantId}:{sessionId}:turn:{n}:ctx     → STRING, TTL 1800
// ✅ Scratchpad helpers with ioredis (Node 20 Lambda)
import Redis from "ioredis";

const redis =
  (globalThis as any).__agentRedis ??
  new Redis({
    host: process.env.REDIS_HOST!,
    port: Number(process.env.REDIS_PORT ?? 6379),
    tls: process.env.REDIS_TLS === "1" ? {} : undefined,
    maxRetriesPerRequest: 2,
    enableReadyCheck: true,
    lazyConnect: false,
  });
(globalThis as any).__agentRedis = redis;

function scratchKey(tenantId: string, sessionId: string) {
  return `agent:${tenantId}:${sessionId}:scratch`;
}

export async function patchScratch(
  tenantId: string,
  sessionId: string,
  patch: Record<string, string>,
  ttlSec = 1800
) {
  const key = scratchKey(tenantId, sessionId);
  const pipe = redis.pipeline();
  for (const [field, value] of Object.entries(patch)) {
    pipe.hset(key, field, value);
  }
  pipe.expire(key, ttlSec);
  await pipe.exec();
}

export async function getScratch(tenantId: string, sessionId: string) {
  return redis.hgetall(scratchKey(tenantId, sessionId));
}

Module-level (or globalThis) client reuse is mandatory on Lambda: establishing TLS to Redis on every cold invoke is a tax; establishing it on every warm invoke is negligence.

Idempotent tool-result keys

Agent runtimes retry. API Gateway times out. Step Functions Map items retry. Without idempotency you double-apply patches.

// ✅ SET NX tool result — first writer wins
export async function cacheToolResult(
  tenantId: string,
  sessionId: string,
  idempotencyKey: string,
  result: unknown,
  ttlSec = 900
): Promise<{ stored: boolean; value: unknown }> {
  const key = `agent:${tenantId}:${sessionId}:tool:${idempotencyKey}`;
  const payload = JSON.stringify(result);
  const ok = await redis.set(key, payload, "EX", ttlSec, "NX");
  if (ok === "OK") return { stored: true, value: result };
  const existing = await redis.get(key);
  return { stored: false, value: existing ? JSON.parse(existing) : null };
}

export async function runToolOnce(
  tenantId: string,
  sessionId: string,
  call: { name: string; args: unknown; idempotencyKey: string }
) {
  const key = `agent:${tenantId}:${sessionId}:tool:${call.idempotencyKey}`;
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const result = await executeTool(call); // side-effecting
  const { value } = await cacheToolResult(
    tenantId,
    sessionId,
    call.idempotencyKey,
    result
  );
  return value;
}

❌ Using only DynamoDB conditional writes for every tool hop is correct but slower. Prefer Redis for the hot cache; optionally mirror final results to DynamoDB for audit. Align keys with AI Coding Agent Tool Schemas.

Connection reuse, AUTH, and VPC

ElastiCache lives in a VPC. Your agent Lambdas need:

  • VPC config (subnets + SG that allow 6379/6380 to the cluster SG)
  • Redis AUTH token or IAM auth (Redis 7+) in Secrets Manager
  • Prefer cluster mode disabled for simple scratchpads unless you shard by tenant deliberately
  • TLS in transit for anything that might hold code snippets or secrets
// ❌ new Redis() inside the handler — handshake every invoke
export async function handler() {
  const r = new Redis({ host: process.env.REDIS_HOST! });
  await r.get("x");
  r.disconnect();
}

// ✅ reuse across warm invokes; handle reconnect
redis.on("error", (err) => console.error("redis_error", err));

For multi-AZ, use ElastiCache replication groups with automatic failover. Accept that a failover can drop in-flight scratch — design agents to rebuild scratch from the last durable session snapshot in DynamoDB.

Contrast with DynamoDB session store

Use DynamoDB Streams TTL hooks (session expiry post) to close WebSockets and revoke grants when the session dies. Redis TTL alone will not notify you to close a socket — pair both stores:

  1. DynamoDB item TTL fires → Stream REMOVE → Lambda closes API Gateway connection, deletes Redis agent:{tenant}:{session}:*
  2. Redis TTL alone expires scratch quietly (fine)
// ✅ Session teardown also purges Redis namespace
export async function purgeSessionCache(tenantId: string, sessionId: string) {
  const pattern = `agent:${tenantId}:${sessionId}:*`;
  let cursor = "0";
  do {
    const [next, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
    cursor = next;
    if (keys.length) await redis.del(...keys);
  } while (cursor !== "0");
}

Wire public agent endpoints behind rate limits (API Gateway WebSockets) so a flood cannot fill Redis with scratch keys.

Operational checklist

  • [ ] Split durable session (DynamoDB) from scratch/tool cache (Redis)
  • [ ] Key by tenantId + sessionId; never shared global scratch keys
  • [ ] SET NX + TTL for tool idempotency keys
  • [ ] Module-level Redis client on Lambda; TLS + AUTH
  • [ ] On DynamoDB session expiry, SCAN/DEL the Redis namespace
  • [ ] Cap scratch size (reject HSET if payload > N KB)
  • [ ] Alert on Redis CPU / evictions — eviction of tool results causes re-exec side effects

Related reading

Last updated on September 20, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.