06-ai-tucanwave: W3filled27 citations

TUCAN Agent Pool — Instance Management

Audiences: developer, internal

TUCAN Agent Pool — Instance Management

AgentPoolService is the process-wide cache of constructed Mastra Agent instances keyed by (tenantSlug, agentType) — built once per tenant + type, reused across turns until the tenant is evicted on org switch or LRU. Replaces per-request Agent construction.

Business Purpose

Constructing a Mastra Agent is not free. Each construction resolves the YAML template, filters the tool catalog by category, renders Jinja2 instructions, builds the model object, and wires memory. Doing this on every turn would:

  • Burn CPU on identical constructions.
  • Cause memory churn (Mastra agents hold callbacks + closures).
  • Slow per-turn latency by 30-100ms (template parse + tool filter on a 350-tool catalog).

The pool gives Dudoxx a O(1) reuse with tenant-aware eviction. A clinic admin switching organizations evicts their pool entries; LRU prevents unbounded growth in long-running processes.

Audiences

  • Developer / partner: To get a constructed agent for a turn, call agentPool.acquire(tenantSlug, agentType, factoryFn). The pool returns the cached instance or runs your factory once. Concurrent calls for the same key share a single construction Promise.
  • Internal (ops / support): Set AGENT_POOL_MAX_ENTRIES=256 by default. Watch the stats() shape if you need to tune for large-fleet deployments. Stale agents after a tenant config change require an evictTenant(slug) call.
  • Investor: One unsexy line: the pool is what lets a tenant scale to thousands of concurrent sessions on one ddx-api process without spawning thousands of duplicate agent constructions.

Architecture

AgentPoolService (singleton, process-wide)

MemberShape / behavior
entriesMap<string, AgentPoolEntry> — key = `${tenantSlug}:${agentType}`, entry = { agent, createdAt, lastUsed }
pendingConstructionMap<string, Promise<PoolableAgent>> — dedupes concurrent async factories per key
maxEntriesdefault 256 → LRU safety net

Public API:

Method
acquire(tenantSlug, agentType, factoryFn)
evictTenant(tenantSlug)
evictEntry(tenantSlug, agentType)
evictAll()
size() / tenantsInPool() / stats()

Callers — each calls acquire and passes a thunk () => buildAgent(...):

Caller
SpecialistAgentFactory.create*
NetworkAgentFactory.createNetworkAgent
AgentFactory (per-turn entry)

Identity, lifetime, concurrency

PropertyRuleSource
Identity(tenantSlug, agentType) — tenant key MUST be the org SLUG, never UUIDagent-pool.service.ts:27-31
LifetimePersists until explicit evictTenant / evictEntry / evictAllagent-pool.service.ts:14-15
TTLNone by defaultagent-pool.service.ts:15
LRU safety netAGENT_POOL_MAX_ENTRIES env, default 256agent-pool.service.ts:16-17, 61, 216-233
ConcurrencySync get-or-construct is safe (single Node thread); async factories dedupe via pendingConstruction mapagent-pool.service.ts:19-25, 124-148
Defensive bypassEmpty tenantSlug → log warning + bypass pool (refuse to pool under empty key)agent-pool.service.ts:108-115

Factory thunk pattern

acquire accepts a factory function, not a constructed agent. The factory is invoked LAZILY only on miss. This means:

  • Cache hit: no construction happens; the bound closure is never evaluated.
  • Cache miss + concurrent calls: only the first call's factory runs; the others await the shared Promise.

This pattern is what lets the specialist and network factories preserve their "construct on first need" semantics while sharing the pool.

Tech Stack & Choices

ChoiceRationale
Process-wide singletonOne pool per ddx-api process; tenant isolation is keyed in, not enforced by separate pools.
Map<string, Entry>O(1) get/put; small constant factor.
Tenant slug as keySSE contract + multi-tenant rules — slug is the only authoritative tenant identifier.
No TTLTenant configs change rarely; explicit eviction is cheaper than periodic refresh.
LRU safety net at 256Single-process upper bound; effectively unreachable in normal use, protects against runaway accumulation.
Async factory dedupe via shared PromiseSingle in-flight construction per key, even under racing concurrent calls.
enforceMaxEntries O(n) on miss-after-insertn is small (≤256); LRU walk is fast; not on hot path.
evictTenant(slug) prefix scanCalled on org switch / config change; not on the hot path.

Alternatives rejected:

  • TTL-based eviction — would refresh templates that haven't changed; cost without benefit.
  • Process-per-tenant pools — would multiply ddx-api memory; the tenant slug key in one pool is sufficient.
  • Mastra-internal caching — would not be tenant-aware; we need the org slug in the key.

Data Flow

Business outcome: Dr. Huber (Hamburg clinic) and Dr. Schmidt (Munich clinic) both make a TUCAN turn at the same time. Each gets a constructed clinical specialist agent; the next turn for Dr. Huber reuses the Hamburg agent without re-running the template parse.

Technical mechanism:

  1. Dispatcher calls AgentFactory.createAgent(context).
  2. Factory builds the pool key from context.organization.slug + 'clinical'.
  3. Factory calls agentPool.acquire('ddx-hamburg-clinic', 'clinical', () => specialistFactory.create({...})).
  4. Pool miss → runs the factory thunk → caches the result → returns the agent.
  5. Concurrent turn for Dr. Schmidt: agentPool.acquire('ddx-munich-clinic', 'clinical', () => specialistFactory.create({...})) → different key → also miss → also constructs.
  6. Next turn for Dr. Huber (~1s later) → cache hit → returns immediately, bumps lastUsed.
  7. Hamburg admin changes template config → API calls agentPool.evictTenant('ddx-hamburg-clinic') → next Hamburg turn reconstructs from new config; Munich is untouched.

Implicated Code

  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:46type PoolableAgent = Agent (Mastra Agent today; Network later).
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:47interface AgentPoolEntry<T> with lastUsed mutable on hit.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:54interface AgentPoolStats.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:61DEFAULT_MAX_ENTRIES = 256.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:66buildPoolKey(tenantSlug, agentType) exported for tests + diagnostics.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:72 — class AgentPoolService.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:74entries: Map<string, AgentPoolEntry>.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:75pendingConstruction: Map<string, Promise<PoolableAgent>>.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:81 — constructor reads AGENT_POOL_MAX_ENTRIES env via ConfigService.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:103acquire<T>(tenantSlug, agentType, factoryFn) public entry.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:108-115 — empty-slug defensive bypass.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:119-122 — cache-hit lastUsed = Date.now() update.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:125-128 — in-flight async construction dedupe.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:130-148 — construction Promise with finally to remove from pending map.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:154evictTenant(tenantSlug) prefix scan.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:173evictEntry(tenantSlug, agentType).
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:178evictAll() (admin / shutdown).
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:192tenantsInPool() distinct slugs.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:202stats() diagnostics snapshot.
  • ddx-api/src/ai/tucan/pool/agent-pool.service.ts:216enforceMaxEntries() LRU safety net.

Operational Notes

  • Tenant key MUST be org slug, NEVER UUID. agent-pool.service.ts:27-31 calls this out explicitly. Mirrors SSE contract channel rules (SSEChannels.org(slug) is slug-keyed) — UUID typechecks but produces wrong cache hits/misses across tenants.
  • Explicit eviction is required on tenant config change. The template-loader doesn't know who is in the pool; the operator (or a tenant-template service hook) must call evictTenant(slug) after a template update.
  • No TTL means stale agents until eviction. If you change the YAML template on disk and restart, the pool starts empty — fine. If you change tenant runtime overrides via the agent-templates REST and DON'T evict, the cached agent stays on the old config. Wire evictTenant into the override write path.
  • AGENT_POOL_MAX_ENTRIES default is 256. For multi-tenant fleets with 100+ active tenants and 5 specialist types each, bump this — otherwise LRU will churn during peak hours.
  • Empty tenant slug = pool bypass + log warning. Catch the warning in tests; in production it means a caller forgot to populate context.organization.slug.
  • PoolableAgent = Agent today but the type is intentionally open. When the orchestrated-dispatch workflow replaces the specialist/network agents, the pool will store the new primitive without API churn.
  • @Optional() configService — if no Nest config is present (unit tests, scripts), defaults to DEFAULT_MAX_ENTRIES = 256.
  • Single Node thread — sync get-or-construct is safe. Multi-process scaling (cluster, PM2) requires per-process pools; this is fine because each process serves a different worker.
  • Concurrent construction dedupe is per-key. Two concurrent acquires for ('hamburg','clinical') share one factory call; one for ('hamburg','clinical') and one for ('hamburg','scheduling') run their factories independently.