resolve_agent_runtime() — SSM registry resolver + consumer migration
An AgentCore runtime ARN is a string that only infra knows and only a Lambda
can use. Until now every consumer learned its ARN the same way: someone
hand-wired it into a Terraform env map or a CI --environment
block. With N consumer lambdas and M agents, that is an
N×M grid of hand-maintained pairings, and every cell is a place a deploy
can quietly drop a variable.
In one week it dropped three: the mira-deploy var-divergence that destroyed
the chatbot IAM role (Bug-2146); the agents-deploy apply that stripped
AGENT_RUNTIME_ARN_MIRA off the broker (Bug-2154); and matter
chat going unroutable because the ws-handler Lambda never received the mira
ARN while the clerk-worker did (Bug-2177). All three are the same disease —
deploy-time coupling — and none of them fail loudly. A
consumer with a missing ARN doesn't crash at boot; it dies at invoke time, or
worse, hangs.
Per pramaan-docs ADR-2178, the cure is runtime service discovery. Each agent deploy publishes its own live ARN to one well-known SSM path. Consumers stop being told their ARNs and start asking for them, by agent name, at the moment they need one.
BEFORE — N x M deploy-time grid, every edge hand-wired:
terraform/CI env maps
├─ ws-handler ← AGENT_RUNTIME_ARN_MIRA (Bug-2177: never arrived)
├─ clerk-worker ← AGENT_RUNTIME_ARN_ATLAS_INTAKE_REVIEW
│ ← AGENT_RUNTIME_ARN_MIRA (Bug-2186: optional, out-of-band)
├─ app-api ← AGENT_RUNTIME_ARN_ATLAS
├─ framer-trigger ← AGENT_RUNTIME_ARN_FRAMER (never set by its workflow)
└─ registrar ← AGENT_RUNTIME_ARN_REGISTRAR
AFTER — one resolver, one registry, N + M edges:
agent deploys ──write──▶ SSM /pramaan/{env}/agents/{name}/runtime-arn
▲
│ resolve_agent_runtime(name)
│ · 60s TTL cache
│ · AgentRuntimeNotRegistered on miss
│
consumers ──────read───────────────┘
clerk-worker → "atlas" + "mira" app-api → "atlas"
framer → "framer" registrar → "registrar"
The clerk-worker resolves two runtimes. agent_runtime_arn is
the Atlas intake/dock runtime; agent_runtime_arn_mira is the
dedicated Mira runtime that matter-chat converse runs invoke (Bug-2186).
_runtime_arn_for picks Mira for matter-chat agent keys and falls
back to Atlas when Mira is unset — so Mira, and only Mira, resolves
optionally.
| Surface | Change |
|---|---|
src/shared/agent_runtime_registry.py (new) |
The one resolver. resolve_agent_runtime(name) reads
/pramaan/{env}/agents/{name}/runtime-arn from SSM, memoises
successes for 60 s, and raises AgentRuntimeNotRegistered
when the parameter is absent or empty. Exports the four roster constants
(AGENT_MIRA, AGENT_ATLAS, AGENT_FRAMER,
AGENT_REGISTRAR) so a rename is one edit, not a string hunt.
|
src/shared/errors/exceptions.py |
New typed AgentRuntimeNotRegistered(PramaanError), code
pramaan.agents.runtime_not_registered, http_status = 503.
An unregistered runtime is now a clean typed WS/broker error, never a
silent hang or an os.environ KeyError.
|
src/clerk_worker/handler.py |
os.environ["AGENT_RUNTIME_ARN_ATLAS_INTAKE_REVIEW"] →
resolve_agent_runtime(AGENT_ATLAS), and
os.environ.get("AGENT_RUNTIME_ARN_MIRA") → a new
_optional_runtime(AGENT_MIRA) that returns None on
AgentRuntimeNotRegistered (and logs), preserving Bug-2186's
fall-back-to-Atlas tolerance rather than failing every launch. This is the
exact mira ARN Bug-2177's ws-handler never received.
_build_deps() is uncached, so the 60 s TTL is honoured
per invocation.
|
src/app_api/router_agents.py |
_atlas_runtime_arn() now delegates to
resolve_agent_runtime(AGENT_ATLAS). Still resolved lazily at
request time; the only caller is the dev-only buffered SSE route.
|
src/workers/framer_trigger/handler.py |
settings.agent_runtime_arn_framer + RuntimeError
guard → resolve_agent_runtime(AGENT_FRAMER). Latent fix:
deploy-framer-trigger.yml never set
AGENT_RUNTIME_ARN_FRAMER at all, so this consumer was one
cold start away from the Bug-2177 failure mode.
|
src/workers/document_registration_consumer/handler.py |
settings.agent_runtime_arn_registrar + RuntimeError
guard → resolve_agent_runtime(AGENT_REGISTRAR).
|
src/shared/settings.py |
Deletes agent_runtime_arn_atlas,
agent_runtime_arn_registrar, and
agent_runtime_arn_framer. A comment in their place tells the
next agent not to re-add them.
|
A negative TTL would mean an agent that registers at t stays invisible to a warm consumer until t+60 s — exactly the window in which a fresh deploy is being smoke-tested. Misses raise and are forgotten, so the very next call sees a newly-published ARN.
AccessDenied must not masquerade as "not registered"
The resolver inspects the botocore error code and re-raises anything that is
not ParameterNotFound. If a consumer role is missing its
ssm:GetParameter grant, that surfaces as a ClientError,
not as AgentRuntimeNotRegistered — otherwise infra would go
hunting a publishing bug when the real fault is an IAM policy. There is a test
pinning exactly this.
Swapping a typed exception in for os.environ.get would have turned
Bug-2186's deliberate tolerance into a hard failure: an unregistered Mira would
crash every clerk-worker launch, including the Atlas runs that never needed
Mira. _optional_runtime keeps that one seam soft — and logs
clerk.agent_runtime_unregistered so a silently-degraded matter chat
is visible in CloudWatch rather than merely quiet. Every other consumer takes
the hard typed failure.
Two coroutines racing the same cold key both issue a GetParameter
and both write the same value. The read is idempotent, so the race costs one
redundant SSM call and corrupts nothing. A lock would buy nothing and cost a
contention path in the invoke hot loop.
resolve_agent_runtime is not a registry-approved manual-span
operation, and its only I/O is a botocore GetParameter call —
already covered by auto-instrumentation as T1 plumbing. Per the guardrail's
own rule ("do not add one span per function"), this new function is
instrumentation-exempt by delegation to the auto-instrumented client. The
AGENT_INVOKE_CALL manual spans on the invoke paths are untouched.
| Test | Pins |
|---|---|
test_name_routes_to_its_own_ssm_path | Each of the four roster names reads its own parameter — no cross-agent bleed. |
test_distinct_names_do_not_share_a_cache_entry | The cache is keyed by name, not global. |
test_cache_hit_inside_ttl_does_not_reread_ssm | Two resolves, one SSM round-trip. |
test_cache_expiry_rereads_ssm | Past 60 s the resolver re-reads, so a redeploy propagates within a minute. |
test_missing_parameter_raises_typed_error | Code, http_status 503, and details carry the agent + parameter path. |
test_failed_resolve_is_not_cached | A just-registered agent resolves on the next call. |
test_empty_parameter_value_raises_typed_error | Registered-but-blank is as unusable as absent. |
test_non_parameter_not_found_client_error_propagates | AccessDenied stays an IAM error. |
test_env_defaults_to_local_when_pramaan_env_unset | Unset PRAMAAN_ENV resolves under /pramaan/local/…. |
test_router_agents_arn.py (rewritten) | The dev SSE broker resolves from the registry and fails fast when unregistered. |
/pramaan/{env}/agents/{name}/runtime-arn. Today only the old
flat params exist (/pramaan/staging/agents/atlas-runtime-arn,
…/registrar-runtime-arn). Until #2180 publishes the nested params
and grants the consumer roles ssm:GetParameter, every
resolve raises AgentRuntimeNotRegistered. Per #2180's own rule —
"resolver first, strip second — never strand consumers" — this PR
merges first and must not be deployed to staging ahead of the infra apply.
{
"Sid": "ReadAgentRuntimeRegistry",
"Effect": "Allow",
"Action": ["ssm:GetParameter"],
"Resource": "arn:aws:ssm:ap-south-1:879602786117:parameter/pramaan/staging/agents/*/runtime-arn"
}
on the roles behind pramaan-ln-staging (clerk-worker/ws-handler),
pramaan-app-api, pramaan-framer-trigger, and
pramaan-document-registration-consumer.
AGENT_RUNTIME_ARN_* from the deploy
workflows. Two reasons. First, sequencing: stripping before #2180
publishes the nested params would strand live consumers. Second, and less
obvious, deploy-document-registration-consumer.yml uses the
resolved ARN to scope the IAM invoke policy
("Resource": "${AGENT_RUNTIME_ARN_REGISTRAR}*" on the
InvokeBlake statement) — that read is load-bearing and is not an
env-var consumption. So rg AGENT_RUNTIME_ARN_ still hits the
workflow YAML; every remaining hit is a deploy-time write or an IAM
scope, and zero are runtime reads. The env-map strip is #2180's
second step.
_build_deps.
That @lru_cache pins the ARN for the warm container's life,
which is exactly the staleness the env var already had. Not a regression;
not this Story's scope.
uv run ruff check # clean on all changed files (120 pre-existing repo errors, identical on main) uv run pytest tests/unit # 1735 passed (+12 new); the 21 failures are DB-less baseline, identical on main uv run mypy src/shared/agent_runtime_registry.py src/shared/errors/exceptions.py # Success make openapi # snapshots byte-identical (no route/DTO change)
Consumers ask the registry for an ARN by agent name at runtime, so a dropped env var can no longer silently unroute an agent.