Free lesson · GenAI Solutions Architecture
Build agent pool manager with lifecycle and capability registration
You will build an AgentPoolManager that manages the full lifecycle of agents across a multi-agent orchestration platform. Define a PooledAgent Pydantic model with fields agent_id: str, capabilities: list[AgentCapability], concurrency_limit: int, current_load: int, health_status: HealthStatus, registered_at: datetime, and last_heartbeat: datetime. The AgentCapability model includes capability_name: str, proficiency_score: float, and required_tools: list[str]. Implement register_agent() in the AgentPoolManager class that validates capability declarations against a known capability taxonomy stored in PostgreSQL table agent_capabilities, inserts the agent into agent_pool table with columns agent_id, pool_name, status, max_concurrency, registered_at, and starts a background heartbeat monitor. Build check_agent_health() that sends periodic probe requests to each agent's /health endpoint, measures response latency, and updates agent_health_checks table. Agents missing three consecutive heartbeats are marked DRAINING and removed from the routing table after in-flight tasks complete. Implement route_to_agent() that accepts a TaskRequest with required_capabilities: list[str] and priority: int, queries the pool for agents matching all required capabilities with current_load < concurrency_limit, scores candidates by proficiency_score * (1 - current_load/concurrency_limit), and returns the best match. Deploy FastAPI endpoints POST /api/v1/agents/register, GET /api/v1/agents/pool/{pool_name}, and DELETE /api/v1/agents/{agent_id}. Emit Prometheus metrics agent_pool_size{pool_name,status}, agent_pool_utilization{pool_name} as the ratio of total current load to total capacity, and agent_routing_latency_seconds{pool_name} histogram tracking time to find a matching agent. Build a Grafana panel showing pool sizes, utilization percentages, and agent health status distribution across all pools. Implement deregister_agent() that transitions an agent to DRAINING status, waits for all in-flight tasks tracked in Redis set inflight:{agent_id} to complete (with a configurable drain_timeout_seconds defaulting to 300), then removes the agent from the pool. Build rebalance_pool() that runs every 60 seconds, detecting pools where utilization exceeds 80% and triggering horizontal scaling recommendations stored in PostgreSQL table scaling_events with columns event_id, pool_name, current_agents, recommended_agents, reason, created_at. Implement AgentCapabilityIndex using a Redis hash capability_index:{capability_name} mapping to sets of agent IDs, enabling O(1) lookup of agents matching a required capability instead of scanning the full pool. Configure Alertmanager rules firing when agent_pool_utilization{pool_name} exceeds 0.85 for more than 5 minutes or when agent_routing_latency_seconds p99 exceeds 2 seconds. Your orchestration layer should target LangGraph 1.0 GA (released October 22, 2025) -- the first stable release with durable state management, built-in persistence (checkpoint-based), and first-class human-in-the-loop support via interrupt nodes, replacing the experimental APIs from earlier 0.x versions. LangGraph 1.0 provides a stable StateGraph contract for production agent orchestration with guaranteed backward compatibility. As a production-ready alternative, evaluate the OpenAI Agents SDK (19k+ GitHub stars, over 10.3 million monthly downloads on PyPI) which offers a lightweight agent framework with clean handoff patterns between agents, built-in tool calling, and guardrail hooks -- it is particularly well-suited for simpler orchestrations that do not require LangGraph's full graph-based state machine capabilities. Your pool manager should support both frameworks: LangGraph-based agents registered with framework: "langgraph" and OpenAI Agents SDK agents with framework: "openai_agents", routing tasks to the appropriate runtime based on the agent's declared framework in its capability registration.
Course: GenAI Architecture & Design Patterns · Chapter 16 · Agent Orchestration Platform
Free to read — no subscription required.
Introduction
When you wire a supervisor to a fleet of specialised agents, the first thing that breaks is bookkeeping: which agent is alive, which one can answer this kind of task, and how many calls is it already juggling. Hardcode agent IDs into plans and every rolling deploy or crashed pod will strand work mid-flight; skip lifecycle tracking and a single dead pod can stall the entire orchestration loop. By the end of this lesson you'll be able to design an agent pool manager that registers capabilities on join, tracks lifecycle state through a fixed state machine, and routes tasks by capability rather than agent ID.
Key Terminology
- Capability: a named, versioned contract (e.g.
rag.search@2.1.0) declaring an input/output schema, amax_concurrency, and a cost class. Plans pin capabilities, never agent IDs. - Agent registration: the join-time handshake where a freshly booted agent posts its
agent_id,endpoint, capability list, andheartbeat_interval_sto the manager so it can be indexed and routed work. - Lifecycle state: the fixed set
Spawning → Registering → Ready → Busy → Draining → Terminatedthat every agent walks through. The manager is the only writer, and every transition is appended to the orchestration log. - Heartbeat: a periodic
POST /heartbeatfrom agent to manager. Missing3 × heartbeat_interval_sof pings flips the agent toTerminatedvia the watcher loop. - Drain: a graceful shutdown signal (typically from a Kubernetes
preStophook) that stops new acquisitions while letting_inflighttasks finish before the pod exits. - Capability index (
_cap_index): the multimapcapability_name → {agent_ids}thatfind_by_capabilitywalks. Stale entries here are how dead pods get handed live work, so every_removemust prune it.
Concepts
Kubernetes-native scaling
Each agent type is a Deployment. The manager's job during scale-out is not to create pods directly — that breaks the reconciliation loop — but to patch the Deployment's replicas field. Kubernetes does the rest; the new pod boots, runs its registration handshake against the manager, and lands in the Ready set.
A typical Deployment skeleton (abridged):
metadata.labels:app=search-agent,capability=rag.search,cap-version=2.1.0spec.template.spec.containers[0].env:MANAGER_URL,AGENT_ID(from downward API, the pod name),HEARTBEAT_INTERVAL_SreadinessProbe: hits/readywhich returns 200 only after the agent has successfully registeredpreStoplifecycle hook: calls the manager's/drainendpoint before SIGTERM
The readiness probe is the trick that keeps Kubernetes' service abstraction honest: a pod that has not registered yet is not in the load balancer rotation. Combined with the preStop drain, you get rolling deploys with zero in-flight task loss, because the manager already knows how to wait for _inflight to hit zero.
Operating discipline
- Treat the registry as ephemeral, not authoritative. If the manager restarts, agents must re-register on next heartbeat. Do not persist registrations; persist the lifecycle log instead.
- Cap concurrency at the agent, not at the manager. Each capability declares its own
max_concurrencybased on what its underlying model or sandbox can sustain. - Emit every transition. Spawning, registering, ready, acquired, released, draining, lost, terminated — all of these go to the orchestration log with timestamp and reason. This is the only way to debug "why did my plan stall."
- Pin capability versions in plans, not agent IDs. A plan that names
rag.search@>=2,<3survives a rolling redeploy. A plan that namessearch-agent-7does not. - Backpressure at
find_by_capability. When zero candidates are available, return empty and let the supervisor decide: queue, spill to a more expensive class, or scale the Deployment up.
Pitfalls
- Heartbeat too aggressive. A 1-second interval with a 3-miss rule will declare every agent dead during a GC pause. Start at 10s/30s and tune down only if you have evidence.
- Forgetting to remove agents from the capability index on terminate. Stale entries cause
find_by_capabilityto return ghosts;acquirethen races on a dead pod. Always remove from_cap_indexinside_remove. - Treating
Busyas a hard lock. Ifmax_concurrency > 1, an agent can be acquired again while busy. Track in-flight count, not a boolean. - Scaling pods without checking pending registrations. A burst of
find_by_capabilitymisses can trigger a scale-up storm if you do not debounce. Coalesce scale requests over a short window (e.g. 5s). - Silent capability drift. Two replicas of the same Deployment advertising different capability versions because the rollout is mid-flight is normal, but the manager must surface it. Log a warning when a capability name has more than one active version simultaneously, and let the supervisor's version pin do the routing.
With this manager in place, the rest of the orchestration platform — supervisor hierarchies, task queues, retry policies — has a stable substrate to build on. Tasks find capable agents, capable agents prove they are alive, and dead ones are reaped without human intervention.
Code Walkthrough
The agent pool as a first-class abstraction
Think of the pool the way a thread pool works in a server runtime, but each "thread" is a specialised mini-service: a search-agent that knows how to issue web queries and summarise, a code-agent running a sandboxed interpreter, a math-agent wrapping a symbolic solver, a retrieval-agent bound to a particular vector index, an image-gen-agent calling a diffusion model. They share no logic — only a contract. That contract is what makes them poolable.
The orchestrator never names a specific agent by ID when planning a task. It names a capability and lets the manager pick. This indirection is what lets us scale, version, and fail over without rewriting plans.
Capability registration: the join handshake
When an agent process starts, the very first thing it does — before accepting any task — is register itself. The registration payload is a self-description: who I am, what I can do, what I cost, how much I can chew at once.
A minimal capability descriptor looks like this:
Code snippetpython
1from pydantic import BaseModel, Field 2from typing import Literal 3 4class Capability(BaseModel): 5 name: str # e.g. "rag.search", "code.python.exec" 6 version: str # semver, e.g. "2.1.0" 7 input_schema: dict # JSON Schema for the task payload 8 output_schema: dict 9 max_concurrency: int = 1 # tasks this agent will run in parallel 10 cost_class: Literal["A", "B", "C"] = "B" # A=cheap, C=expensive 11 12class AgentRegistration(BaseModel): 13 agent_id: str # unique, e.g. pod name 14 deployment: str # k8s Deployment name 15 endpoint: str # gRPC/HTTP address 16 capabilities: list[Capability] 17 heartbeat_interval_s: int = 10 18 metadata: dict = Field(default_factory=dict)
The manager indexes registrations by (capability.name, capability.version). Because the same agent may advertise multiple capabilities — a code-agent that does both code.python.exec and code.python.lint — the index is a multimap, not a 1:1 mapping.
Why version the capability, not the agent
Agents are cattle; capabilities are contracts. If you bump the agent image from 1.4.7 to 1.4.8 for a bug fix, the supervisor does not need to know. But if you change the input schema for rag.search — say, you add a required tenant_id field — that is a breaking contract change and the capability version must move to 3.0.0. Plans pinned to >=2,<3 will route to the old fleet until they are migrated. This is the same discipline as API versioning, just inside the agent mesh.
The lifecycle state machine
Every agent moves through a fixed set of states. The manager is the only writer of these transitions, and every transition is appended to the orchestration log so that post-mortems and replays are possible.
Two transitions deserve a closer look. Drain is graceful: the agent stops accepting new work but finishes whatever it is currently doing, then exits. This is what runs during a rolling deploy. Heartbeat-lost-to-Terminated is the brutal path: if the watcher has not seen a ping within the agreed interval (typically 3 missed heartbeats), the manager declares the agent dead, removes it from the registry, and asks Kubernetes to confirm the pod is gone or to delete it.
The AgentPoolManager class
Here is the core of the manager. It is intentionally small: registration, lookup, acquire/release with concurrency tracking, and drain. Heartbeat handling and the K8s scaler are wired in around this skeleton.
Code snippetpython
1import asyncio 2import time 3from collections import defaultdict 4from packaging.version import Version, InvalidVersion 5 6class AgentPoolManager: 7 def __init__(self, k8s_client, log_sink): 8 self._agents: dict[str, AgentRegistration] = {} 9 self._state: dict[str, str] = {} # agent_id -> state 10 self._inflight: dict[str, int] = defaultdict(int) 11 self._last_seen: dict[str, float] = {} 12 self._cap_index: dict[str, set[str]] = defaultdict(set) # cap_name -> agent_ids 13 self._lock = asyncio.Lock() 14 self._k8s = k8s_client 15 self._log = log_sink 16 17 async def register(self, reg: AgentRegistration) -> None: 18 async with self._lock: 19 self._agents[reg.agent_id] = reg 20 self._state[reg.agent_id] = "Ready" 21 self._last_seen[reg.agent_id] = time.time() 22 for cap in reg.capabilities: 23 self._cap_index[cap.name].add(reg.agent_id) 24 await self._log.emit("agent.registered", reg.agent_id, 25 caps=[c.name for c in reg.capabilities]) 26 27 async def find_by_capability(self, name: str, version_spec: str = ">=0") -> list[str]: 28 async with self._lock: 29 candidates = [] 30 for aid in self._cap_index.get(name, set()): 31 if self._state.get(aid) != "Ready": 32 continue 33 reg = self._agents[aid] 34 cap = next(c for c in reg.capabilities if c.name == name) 35 if _matches(cap.version, version_spec): 36 if self._inflight[aid] < cap.max_concurrency: 37 candidates.append(aid) 38 # cheapest first, then least loaded 39 candidates.sort(key=lambda a: ( 40 self._cost_rank(a, name), self._inflight[a])) 41 return candidates 42 43 async def acquire(self, agent_id: str) -> None: 44 async with self._lock: 45 reg = self._agents[agent_id] 46 cap_max = max(c.max_concurrency for c in reg.capabilities) 47 if self._inflight[agent_id] >= cap_max: 48 raise RuntimeError("agent saturated") 49 self._inflight[agent_id] += 1 50 if self._inflight[agent_id] == cap_max: 51 self._state[agent_id] = "Busy" 52 await self._log.emit("agent.acquired", agent_id) 53 54 async def release(self, agent_id: str) -> None: 55 async with self._lock: 56 self._inflight[agent_id] = max(0, self._inflight[agent_id] - 1) 57 if self._state.get(agent_id) == "Busy": 58 self._state[agent_id] = "Ready" 59 await self._log.emit("agent.released", agent_id) 60 61 async def drain(self, agent_id: str) -> None: 62 async with self._lock: 63 self._state[agent_id] = "Draining" 64 await self._log.emit("agent.draining", agent_id) 65 # wait for in-flight to settle, then scale the deployment down 66 while self._inflight[agent_id] > 0: 67 await asyncio.sleep(0.5) 68 await self._k8s.delete_pod(self._agents[agent_id].deployment, agent_id) 69 async with self._lock: 70 self._remove(agent_id) 71 await self._log.emit("agent.terminated", agent_id, reason="drain") 72 73 async def heartbeat_watcher(self) -> None: 74 # wakes every second; anything older than 3 * heartbeat_interval_s is dead 75 while True: 76 now = time.time() 77 async with self._lock: 78 dead = [] 79 for aid, reg in self._agents.items(): 80 if self._state.get(aid) in ("Terminated", "Draining"): 81 continue 82 if now - self._last_seen[aid] > 3 * reg.heartbeat_interval_s: 83 dead.append(aid) 84 for aid in dead: 85 await self._log.emit("agent.lost", aid) 86 await self._k8s.delete_pod(self._agents[aid].deployment, aid) 87 async with self._lock: 88 self._remove(aid) 89 await asyncio.sleep(1.0)
The find_by_capability method is where the cost class actually pays off: when several agents qualify, the manager prefers the cheapest cost class first and the least-loaded agent within that class. That single line of policy is enough to keep expensive GPU-backed agents idle until the cheap ones are saturated.
Heartbeats and the watcher loop
The heartbeat_watcher method shown above runs as a background coroutine, waking every second to check _last_seen. Anything older than 3 * heartbeat_interval_s is presumed dead: the manager emits agent.lost, asks Kubernetes to delete the pod, and prunes it from the registry. The agent itself posts POST /heartbeat against the manager on its declared interval; the handler simply updates _last_seen[agent_id] = time.time(). Cheap, idempotent, and survives network blips up to the 3-miss threshold.
Do's and Don'ts
Do's
- ✓Do version capabilities as contracts, not agent images — when
rag.searchgains a requiredtenant_idfield in itsinput_schema, bumpcapability.versionto3.0.0so plans pinned to>=2,<3keep routing to the old fleet without a single plan rewrite; a pod image bump from1.4.7to1.4.8for a bug fix requires no version change at all. - ✓Do make
AgentPoolManagerthe sole writer of lifecycle state transitions — every move through the state machine (Registering → Ready,Ready → Draining, heartbeat-lost →Terminated) must be appended to the orchestration log with timestamp and reason, because that append-only log is the only artifact that can answer why a plan stalled after a pod died mid-flight. - ✓Do gate
acquire()on the per-agent_inflightcounter rather than theBusystate label — an agent withmax_concurrency > 1stays inBusywhile running concurrent tasks, so blocking new work the moment the label flips starves capacity thatmax_concurrencyexplicitly licensed; compare_inflight[agent_id] < cap.max_concurrency, notstate != "Busy".
Don'ts
- ✗Don't bind orchestration plans to specific agent IDs like
search-agent-7— usefind_by_capability('rag.search', '>=2')so that pod restarts, rolling deploys, and Kubernetes-driven scale events do not strand in-flight work; the entire point of the capability registry indirection is that the supervisor never names the agent. - ✗Don't omit the
_cap_indexcleanup inside_remove— if a dead agent's ID lingers in thecap.name → set[agent_ids]multimap after termination,find_by_capabilitywill hand the caller a pod that no longer exists, and the subsequentacquire()call silently fails or hangs with no obvious trace back to the stale index entry. - ✗Don't set
heartbeat_interval_sto 1s with a 3-miss rule — a routine GC pause in an agent process can exceed 3 seconds and trigger a mass-eviction of healthy pods; the state machine'sReady → Terminatededge on heartbeat loss is intentionally blunt, so start at the lesson's reference point of 10s per beat (30s total window) and tighten only after measuring actual pause distributions.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Solutions Architecture subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in GenAI Architecture & Design Patterns
- Ch 12Validate A2A communication reliability with failure injection
- Ch 12Build A2A agent trust and authorization framework
- Ch 12Optimize A2A network topology for latency and reliability
- Ch 12Create A2A network operations dashboard with federation view
- Ch 13Implement event backbone with Redis Streams for AI workloads
- Ch 16Build agent pool manager with lifecycle and capability registrationYou are here
- Ch 16Validate orchestration correctness with agent trajectory evaluation