elan-agentkit-runtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,448 @@
1
+ """Turn a discovered target into a runnable ADK App.
2
+
3
+ Agents and workflows share the entire stack — Runner, plugins, session service,
4
+ artifact service. In ADK 2.0 ``BaseAgent`` subclasses ``BaseNode`` and ``App``'s
5
+ validator accepts either, so a ``Workflow`` drops into ``root_agent`` unchanged.
6
+ This module is the only place that branches on ``kind``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from agentkit_core.manifest import BaseTargetManifest, ToolBinding, WorkflowManifest
17
+ from google.adk.apps import App
18
+ from google.adk.apps._configs import EventsCompactionConfig, ResumabilityConfig
19
+ from google.adk.tools.agent_tool import AgentTool
20
+
21
+ from agentkit_runtime.config import Settings
22
+ from agentkit_runtime.registry import Target
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def mcp_url_env_var(ref: str) -> str:
28
+ """`echo` -> `MCP_ECHO_URL`."""
29
+ return f"MCP_{ref.replace('-', '_').upper()}_URL"
30
+
31
+
32
+ def resolve_tool_url(binding: ToolBinding) -> str:
33
+ """Where this application actually lives.
34
+
35
+ The manifest carries a local-development default; the environment overrides
36
+ it per deployment. That keeps deployment addresses out of source without
37
+ making a target unrunnable on a laptop.
38
+ """
39
+ override = os.environ.get(mcp_url_env_var(binding.ref))
40
+ url = override or binding.url
41
+ if not url:
42
+ raise ValueError(
43
+ f"tool {binding.ref!r} has no url: set it in the manifest for local "
44
+ f"development, or {mcp_url_env_var(binding.ref)} in the environment"
45
+ )
46
+ return url
47
+
48
+
49
+ def build_tools(manifest: BaseTargetManifest) -> list[Any]:
50
+ """Turn the manifest's tool bindings into ADK toolsets."""
51
+ tools: list[Any] = []
52
+
53
+ for binding in getattr(manifest, "tools", []):
54
+ if binding.kind != "mcp":
55
+ raise ValueError(
56
+ f"{manifest.slug}: tool kind {binding.kind!r} is not supported yet"
57
+ )
58
+
59
+ # Imported here, not at module scope: google-adk wraps its MCP imports
60
+ # in a try/except, so a missing or wrong-major `mcp` makes McpToolset
61
+ # silently absent rather than raising. Failing at build time names the
62
+ # agent that wanted it.
63
+ from google.adk.tools.mcp_tool import (
64
+ McpToolset,
65
+ StreamableHTTPConnectionParams,
66
+ )
67
+
68
+ tools.append(
69
+ McpToolset(
70
+ connection_params=StreamableHTTPConnectionParams(
71
+ url=resolve_tool_url(binding)
72
+ ),
73
+ # None means "every tool the server offers". Naming them pins
74
+ # the agent's surface, so a new tool on a shared application
75
+ # does not silently appear in an unrelated agent's prompt.
76
+ tool_filter=binding.tool_filter,
77
+ )
78
+ )
79
+
80
+ return tools
81
+
82
+
83
+ class WorkflowTool(AgentTool):
84
+ """A workflow, offered to an agent as a tool it can call.
85
+
86
+ ADK's own `AgentTool` already runs a sub-agent in-process and a `Workflow`
87
+ IS an agent, so almost all of this is inherited. What it cannot do is read a
88
+ graph's inputs: `agent_tool._get_input_schema` handles an `LlmAgent` and
89
+ then falls through to `agent.sub_agents`, which a `Workflow` does not have —
90
+ so wrapping one in a plain AgentTool raises
91
+
92
+ AttributeError: 'Workflow' object has no attribute 'sub_agents'
93
+
94
+ before it can offer the tool at all. Overriding the declaration is therefore
95
+ not an optimisation; it is the thing that makes this work.
96
+
97
+ The schema comes from the callee's own manifest, so the model is offered the
98
+ workflow's declared inputs as typed parameters rather than the bare
99
+ `request: string` AgentTool falls back to — which is the exact prose-packing
100
+ that declaring inputs was meant to remove.
101
+
102
+ `tests/test_agent_tools.py` pins both halves. If a later ADK teaches
103
+ `_get_input_schema` about Workflow, this class can go.
104
+ """
105
+
106
+ def _get_declaration(self) -> Any:
107
+ schema = getattr(self.agent, "input_schema", None)
108
+ if schema is None:
109
+ # Never delegate: super() raises on a Workflow rather than falling
110
+ # back, so an empty schema has to be a clear error of our own.
111
+ raise ValueError(
112
+ f"{self.agent.name}: a workflow offered as a tool must declare "
113
+ "inputs in its manifest, or the calling model has no parameters "
114
+ "to fill in"
115
+ )
116
+
117
+ from google.adk.tools import _automatic_function_calling_util
118
+
119
+ declaration = _automatic_function_calling_util.build_function_declaration(
120
+ func=schema, variant=self._api_variant
121
+ )
122
+ declaration.name = self.name
123
+ declaration.description = self.agent.description or self.agent.name
124
+ return declaration
125
+
126
+
127
+ def workflow_tool(slug: str, settings: Settings) -> WorkflowTool:
128
+ """Resolve another target by slug and hand it back as a callable tool.
129
+
130
+ The callee is built from ITS OWN manifest — its inputs, its node overrides,
131
+ its model — so a workflow behaves identically whether the portal launched it
132
+ or an agent called it. That also means the caller's container must contain
133
+ the callee's code, which it does: one image holds every target.
134
+ """
135
+ from agentkit_runtime.registry import resolve
136
+
137
+ target = resolve(slug, settings)
138
+ if not isinstance(target.manifest, WorkflowManifest):
139
+ raise ValueError(f"{slug!r} is an agent, not a workflow; use its MCP tools")
140
+
141
+ ctx = BuildContext(manifest=target.manifest, settings=settings)
142
+ root = target.build(ctx)
143
+ # The description is what the calling model reads to decide whether this is
144
+ # the right tool, so it comes from the manifest rather than being restated.
145
+ root.description = target.manifest.description
146
+ # The same model the graph already validates its launch state against, now
147
+ # doing double duty as the tool's parameter list. One declaration, so the
148
+ # form the model fills in cannot drift from the state the nodes bind.
149
+ root.input_schema = target.manifest.state_model()
150
+
151
+ # ADK assumes every non-LlmAgent it is handed has `sub_agents`. Both
152
+ # `_get_input_schema` and `_get_output_schema` probe it, and a Workflow does
153
+ # not declare the field — so without this the tool raises
154
+ #
155
+ # AttributeError: 'Workflow' object has no attribute 'sub_agents'
156
+ #
157
+ # not only when declaring itself but again inside `run_async`, i.e. at the
158
+ # moment the model actually calls it. Overriding the declaration alone fixes
159
+ # the first and leaves the second, which fails as a run-time error in front
160
+ # of a user rather than at build time.
161
+ #
162
+ # `object.__setattr__` because pydantic refuses an undeclared field. The
163
+ # value is true — a graph has no sub-agents — and it makes both helpers
164
+ # return None, so `run_async` JSON-dumps the arguments into the message that
165
+ # the first node parses.
166
+ object.__setattr__(root, "sub_agents", [])
167
+
168
+ # include_plugins=False, deliberately.
169
+ #
170
+ # ADK's default re-runs the caller's plugins for the nested invocation, and
171
+ # this platform's plugins are not re-entrant: the ledger plugin opens a
172
+ # SECOND hold on `before_run_callback` that nothing ever settles, so a
173
+ # single check-in left an orphaned reservation behind for the sweeper to
174
+ # expire. Measured on one conversation: four model calls, three holds, two
175
+ # settles.
176
+ #
177
+ # The nested run is part of the caller's invocation, not a new one — and
178
+ # this platform's own rule is that the invocation is the unit of
179
+ # idempotency, billing and tracing. So the caller's hold covers it, and its
180
+ # `max_llm_calls` still bounds the nested run because ADK forwards the
181
+ # caller's run_config.
182
+ #
183
+ # The cost is that the workflow's own model calls are missing from the
184
+ # invocation summary's `llm_calls`, and its nodes emit no typing status.
185
+ # Under-reported telemetry is the better failure of the two.
186
+ return WorkflowTool(agent=root, include_plugins=False)
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class BuildContext:
191
+ """Everything a target's ``build()`` is allowed to depend on.
192
+
193
+ Deliberately narrow: a target should not reach for global state, so anything
194
+ it needs (its own manifest, resolved settings) is handed to it explicitly.
195
+ """
196
+
197
+ manifest: BaseTargetManifest
198
+ settings: Settings
199
+
200
+ @property
201
+ def slug(self) -> str:
202
+ return self.manifest.slug
203
+
204
+ def tools(self) -> list[Any]:
205
+ """The manifest's tool bindings, resolved to ADK toolsets.
206
+
207
+ A target calls this from build() rather than receiving tools
208
+ pre-attached, so an agent that wants to wrap or reorder them can.
209
+ """
210
+ return build_tools(self.manifest)
211
+
212
+ def workflow_tool(self, slug: str) -> Any:
213
+ """Another target's graph, as a tool this agent can call.
214
+
215
+ The agent declares WHICH workflow; the workflow keeps declaring its own
216
+ inputs. Neither restates the other.
217
+ """
218
+ return workflow_tool(slug, self.settings)
219
+
220
+ def state_schema(self) -> Any:
221
+ """The manifest's declared inputs as a Pydantic model, or None.
222
+
223
+ Pass it to ``Workflow(state_schema=ctx.state_schema())``. ADK then
224
+ refuses at construction time to build a graph whose FunctionNode asks
225
+ for a parameter the manifest never declared — which is the whole reason
226
+ the schema is derived from the declaration rather than written twice.
227
+ """
228
+ if isinstance(self.manifest, WorkflowManifest):
229
+ return self.manifest.state_model()
230
+ return None
231
+
232
+ @property
233
+ def model(self) -> str:
234
+ """The manifest's model, unless local development substitutes one.
235
+
236
+ A PLAIN STRING, AND THE AGENT'S SDK CALLS THE PROVIDER DIRECTLY. There
237
+ was briefly a gateway in this path — `ctx.model` returned a `Gemini`
238
+ subclass whose client pointed at us — and it was removed because it
239
+ bought less than it cost:
240
+
241
+ IT ONLY EVER COVERED GEMINI. ADK resolves a bare string through
242
+ `LLMRegistry`, which knows Gemini and nothing else; a tenant using
243
+ OpenAI writes `LiteLlm(model="openai/gpt-4o")` in their own agent
244
+ code, which never passes through here. So the control applied to
245
+ one provider and silently not to the others.
246
+
247
+ IT PUT US ON THE CRITICAL PATH of every model call, and made
248
+ streaming, tool calling and multimodal our problem to keep faithful
249
+ across two SDK protocols.
250
+
251
+ UNDER BYOK THE PROVIDER BILLS THE TENANT DIRECTLY, so the budget it
252
+ enforced was the tenant's own guard rail rather than anything
253
+ protecting the platform or their neighbours.
254
+
255
+ WHAT REPLACED IT IS A NETWORK CONTROL, which is stronger for the one
256
+ thing that mattered: the tenant's egress allowlist is DERIVED from
257
+ `llm.models`, so a pod can reach the providers of the models it
258
+ declared and no others. That holds whatever SDK the agent uses,
259
+ including one we have never heard of.
260
+
261
+ MODEL_OVERRIDE RAISES rather than warns outside local development. A
262
+ warning was the previous behaviour and it is not enough: the
263
+ deploy-time cross-check asserts `MANIFEST.model` is a member of the
264
+ tenant's `llm.models`, and MODEL_OVERRIDE substitutes the model AFTER
265
+ that check passed. The console then shows one model, the agent calls
266
+ another, and the allowlist the tenant agreed to describes a value
267
+ nothing uses — with a log line nobody reads as the only trace.
268
+
269
+ That matters more now, not less: with the allowlist enforced as an
270
+ egress rule derived from `llm.models`, an overridden model whose
271
+ provider is not in that set fails as a connection timeout rather than
272
+ as a policy refusal.
273
+ """
274
+ if self.settings.model_override:
275
+ if self.settings.agentkit_env != "local":
276
+ raise ValueError(
277
+ "MODEL_OVERRIDE is set outside local development. It substitutes "
278
+ f"{self.manifest.slug}'s manifest model after the deploy-time "
279
+ "model allowlist has already been checked, so the allowlist would "
280
+ "no longer describe what runs. Set AGENTKIT_ENV=local to use it."
281
+ )
282
+ logger.warning(
283
+ "MODEL_OVERRIDE=%s is shadowing %s's manifest model — local dev only",
284
+ self.settings.model_override,
285
+ self.manifest.slug,
286
+ )
287
+ return self.settings.model_override
288
+ return getattr(self.manifest, "model", "gemini-flash-latest")
289
+
290
+
291
+ def _apply_node_overrides(root: Any, manifest: WorkflowManifest) -> None:
292
+ """Apply per-node timeout/retry from the manifest onto the built graph.
293
+
294
+ Operational knobs belong in the manifest (inspectable, versioned, used by CI)
295
+ while topology stays in code, so they are stitched together here.
296
+ """
297
+ if not manifest.nodes:
298
+ return
299
+ graph = getattr(root, "graph", None)
300
+ nodes = list(getattr(graph, "nodes", []) or [])
301
+ by_name = {getattr(n, "name", None): n for n in nodes}
302
+
303
+ unknown = set(manifest.nodes) - set(by_name)
304
+ if unknown:
305
+ raise ValueError(
306
+ f"{manifest.slug}: manifest.nodes names not in the graph: {sorted(unknown)}"
307
+ )
308
+
309
+ for name, override in manifest.nodes.items():
310
+ node = by_name[name]
311
+ if override.timeout is not None:
312
+ node.timeout = override.timeout
313
+ if override.max_attempts is not None:
314
+ from google.adk.workflow import RetryConfig
315
+
316
+ node.retry_config = RetryConfig(max_attempts=override.max_attempts)
317
+
318
+
319
+ def app_name_of(slug: str, settings: Settings) -> str:
320
+ """ADK's `app_name` for this target — the column every session is keyed on.
321
+
322
+ TENANT-QUALIFIED WHEN THERE IS A TENANT (ADR-0054, finally applied in
323
+ ADR-0145). `AgentRef.app_name` has composed `<tenant>.<project>.<agent>`
324
+ since ADR-0054 and TWO READERS HAVE ALWAYS ASKED FOR IT — rt-service's two
325
+ session routes, and the IAM condition that scopes a tenant's artifact
326
+ bucket to its own prefix — while this function passed the bare slug. So
327
+ both routes answered 404 for every tenant, and the artifact grant named a
328
+ prefix nothing wrote to. Measured on np: a pod carrying `TENANT_SLUG=rsl`
329
+ and `PROJECT_SLUG=salmon` wrote `app_name = 'fish-facts'`.
330
+
331
+ IT ORPHANS EVERY SESSION WRITTEN UNDER THE OLD NAME, because ADK keys a
332
+ session on `(app_name, user_id, id)`. `scripts/migrate_app_name.py` renames
333
+ the four ADK tables that carry it, and must be run after deploying this —
334
+ deploy FIRST, so anything the outgoing image writes in the gap is swept up
335
+ by the same pass.
336
+
337
+ THE BARE SLUG SURVIVES WHERE THERE IS NO TENANT, which is local
338
+ development and the whole test suite. Same condition `fleet.py` already
339
+ uses to decide whether it can derive subscription names, and for the same
340
+ reason: there is nothing to derive from.
341
+ """
342
+ if settings.tenant_slug and settings.project_slug:
343
+ from agentkit_core.identity import AgentRef # noqa: PLC0415
344
+
345
+ return AgentRef(settings.tenant_slug, settings.project_slug, slug).app_name
346
+ return slug
347
+
348
+
349
+ def build_app(target: Target, settings: Settings, plugins: list[Any] | None = None) -> App:
350
+ """Build the ADK App for this container's target."""
351
+ manifest = target.manifest
352
+ ctx = BuildContext(manifest=manifest, settings=settings)
353
+ root = target.build(ctx)
354
+
355
+ resumability = None
356
+ if isinstance(manifest, WorkflowManifest):
357
+ # A declaration nothing enforces is a comment. ADK does the enforcing,
358
+ # but only if the graph was constructed WITH the schema — and it cannot
359
+ # be attached here after the fact, because pydantic's model_post_init
360
+ # does not re-run on assignment and the validation would silently not
361
+ # happen.
362
+ if manifest.inputs and getattr(root, "state_schema", None) is None:
363
+ raise ValueError(
364
+ f"{manifest.slug}: the manifest declares inputs but the graph has no "
365
+ f"state_schema. Pass state_schema=ctx.state_schema() to Workflow(...), "
366
+ f"or nothing checks that the nodes' parameters match the launch form."
367
+ )
368
+ _apply_node_overrides(root, manifest)
369
+ if manifest.max_concurrency is not None:
370
+ root.max_concurrency = manifest.max_concurrency
371
+ if manifest.resumable:
372
+ # Lets the workflow pause on a long-running tool and resume from the
373
+ # last event, which is what keeps a human-approval step from having
374
+ # to hold a push request open past the 600s ack deadline.
375
+ resumability = ResumabilityConfig(is_resumable=True)
376
+
377
+ return App(
378
+ name=app_name_of(manifest.slug, settings),
379
+ root_agent=root,
380
+ plugins=plugins or [],
381
+ resumability_config=resumability,
382
+ events_compaction_config=_compaction(
383
+ manifest, root, target.module_name.rpartition(".")[0]),
384
+ )
385
+
386
+
387
+ def _compaction(manifest, root, package: str = "") -> EventsCompactionConfig | None:
388
+ """`MANIFEST.compaction`, as ADK's config — or None, which is the default.
389
+
390
+ NO SUMMARIZER IS PASSED, AND THAT IS THE DESIGN. ADK builds one from the
391
+ root agent's own model, which is the model `cross_check` already validated
392
+ against `elan.yaml`'s `llm.models`. Passing our own would introduce a
393
+ second model into a path that has exactly one rule about models.
394
+
395
+ THE `LlmAgent` CHECK IS HERE BECAUSE ADK'S IS IN THE WRONG PLACE. Its
396
+ `_ensure_compaction_summarizer` raises when the root is not an `LlmAgent`,
397
+ from inside the POST-INVOCATION hook — and the runner notifies and
398
+ re-raises, so the agent answers correctly, the caller sees the reply, and
399
+ then the run is marked failed. Every time, once the threshold is reached.
400
+
401
+ A `WorkflowManifest` has no `compaction` field at all, so the only way to
402
+ reach this is an agent whose `build()` returns something other than an
403
+ `LlmAgent` — a `SequentialAgent`, a custom `BaseAgent`. Refusing at pod
404
+ start is not as good as refusing the deploy, which is not possible without
405
+ running `build()` in the builder; it is much better than a run that fails
406
+ after it has answered.
407
+ """
408
+ wanted = getattr(manifest, "compaction", None)
409
+ if wanted is None:
410
+ return None
411
+
412
+ from google.adk.agents.llm_agent import LlmAgent # noqa: PLC0415
413
+
414
+ summarizer = None
415
+ if wanted.summarizer:
416
+ from google.adk.apps.base_events_summarizer import ( # noqa: PLC0415
417
+ BaseEventsSummarizer,
418
+ )
419
+
420
+ from agentkit_runtime.tenant_plugins import load_one # noqa: PLC0415
421
+
422
+ summarizer = load_one(wanted.summarizer, package,
423
+ expect=BaseEventsSummarizer,
424
+ what="compaction summarizer")
425
+
426
+ # A DECLARED SUMMARISER MAKES THE ROOT'S TYPE IRRELEVANT, which is ADK's
427
+ # own rule rather than a concession: `_ensure_compaction_summarizer`
428
+ # returns early when one is set and never asks what the root is. So the
429
+ # check below guards only the case where ADK would have to derive one.
430
+ if summarizer is None and not isinstance(root, LlmAgent):
431
+ raise ValueError(
432
+ f"{manifest.slug}: compaction is configured, but build() returned "
433
+ f"a {type(root).__name__} rather than an LlmAgent. ADK builds the "
434
+ "summariser from the root agent's own model and has none to use "
435
+ "here — and it would raise AFTER an invocation had already "
436
+ "answered, failing a run that worked. Remove the compaction block, "
437
+ "or return an LlmAgent as the root."
438
+ )
439
+
440
+ # PASSED AS PAIRS, because ADK validates them that way and the manifest
441
+ # has already refused half of one.
442
+ return EventsCompactionConfig(
443
+ summarizer=summarizer,
444
+ token_threshold=wanted.after_tokens,
445
+ event_retention_size=wanted.keep_recent_events,
446
+ compaction_interval=wanted.after_invocations,
447
+ overlap_size=wanted.overlap,
448
+ )
@@ -0,0 +1,46 @@
1
+ """Per-container invocation concurrency.
2
+
3
+ Push subscriptions have no ``max_outstanding_messages`` — that knob is pull-only.
4
+ For push the throttle is Cloud Run concurrency x max-instances plus returning 429
5
+ so Pub/Sub redelivers with exponential backoff. This is the in-process half.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from contextlib import asynccontextmanager
12
+
13
+ from agentkit_core.errors import SaturatedError
14
+
15
+
16
+ class InvocationLimiter:
17
+ """Bounded concurrent invocations, with a short wait before shedding load.
18
+
19
+ The brief wait matters: without it a burst that would clear in milliseconds
20
+ turns into a redelivery round-trip. Past the deadline we shed rather than
21
+ queue, because a queued push message is one that will hit its ack deadline.
22
+ """
23
+
24
+ def __init__(self, max_concurrent: int, acquire_timeout_s: float = 2.0) -> None:
25
+ self._semaphore = asyncio.Semaphore(max_concurrent)
26
+ self._acquire_timeout_s = acquire_timeout_s
27
+ self.max_concurrent = max_concurrent
28
+ self._in_flight = 0
29
+
30
+ @property
31
+ def in_flight(self) -> int:
32
+ return self._in_flight
33
+
34
+ @asynccontextmanager
35
+ async def slot(self):
36
+ try:
37
+ await asyncio.wait_for(self._semaphore.acquire(), timeout=self._acquire_timeout_s)
38
+ except TimeoutError as exc:
39
+ raise SaturatedError(f"all {self.max_concurrent} invocation slots busy") from exc
40
+
41
+ self._in_flight += 1
42
+ try:
43
+ yield
44
+ finally:
45
+ self._in_flight -= 1
46
+ self._semaphore.release()
@@ -0,0 +1,188 @@
1
+ """Runtime settings.
2
+
3
+ Discipline rule from the plan: no GCP project id, region, bucket name, topic
4
+ prefix or database name is ever a literal in source. They arrive as environment
5
+ variables, rendered from config/platform.yaml.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+
16
+ def _default_targets_root() -> Path:
17
+ # <repo>/agent-pipeline/runtime/src/agentkit_runtime/config.py -> <repo>/agent-pipeline
18
+ return Path(__file__).resolve().parents[3]
19
+
20
+
21
+ class Settings(BaseSettings):
22
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
23
+
24
+ # --- which target(s) this container is ---
25
+ target_slug: str = ""
26
+ """ONE target. The isolated case, and what every rendered pod sets today.
27
+
28
+ Kept beside `target_slugs` rather than replaced by it because the two say
29
+ different things to whoever reads the ConfigMap: one pod running one agent
30
+ is the ordinary case and should not have to look like a degenerate list."""
31
+
32
+ target_slugs: str = ""
33
+ """SEVERAL targets, comma separated — a GROUP sharing one workload
34
+ (ADR-0064).
35
+
36
+ A group exists because Autopilot bills every pod at a 250m/512Mi floor
37
+ whatever it requests, so five small agents cost 2.5 vCPU before doing any
38
+ work. Sharing a pod trades independent scaling and rollback for that.
39
+
40
+ NAMES ONLY. Everything else a member needs — its subscriptions, its dead
41
+ letter topic — is DERIVED from `AgentRef`, so adding a target to a group
42
+ does not mean adding three more environment variables that can disagree
43
+ with each other."""
44
+ build_sha: str = "dev"
45
+ targets_root: Path = _default_targets_root()
46
+
47
+ # --- persistence ---
48
+ database_url: str = ""
49
+ """SQLAlchemy async URL, e.g. postgresql+asyncpg://... Empty -> in-memory sessions."""
50
+
51
+ artifact_bucket: str = ""
52
+ """Empty -> in-memory artifact service (local dev)."""
53
+
54
+ # --- messaging ---
55
+ broker: str = "auto"
56
+ """Which message broker to use: auto, gcp or memory. `auto` picks gcp when
57
+ PUBSUB_PROJECT_ID is set and memory otherwise — the historical behaviour.
58
+ Name it explicitly on a cloud that has no project id, or `auto` degrades to
59
+ in-memory and the runtime looks healthy while publishing nowhere."""
60
+
61
+ pubsub_project_id: str = ""
62
+
63
+ tenant_slug: str = ""
64
+ """Which tenant owns this agent. Empty in local development, where there is
65
+ only one. Names the per-tenant event topic and the agent's namespace."""
66
+
67
+ project_slug: str = ""
68
+ """Which project owns this agent (ADR-0054). Empty in local development.
69
+
70
+ WITHOUT IT A POD CANNOT NAME ITS OWN TOPICS. Every provisioned name is
71
+ `<tenant>.<project>.<agent>`, so a runtime holding only the tenant can
72
+ derive none of them — which is how the dead-letter topic came to default to
73
+ `agent.invoke.<agent>.dlq`, a name nothing ever created. Poison messages
74
+ could not be forwarded at all."""
75
+
76
+ events_topic: str = "agent.events"
77
+ """PER TENANT in a cluster (`agent.events.<tenant>`), rendered into the pod's
78
+ config by the deploy service.
79
+
80
+ The shared default is local development only. Shared was a cross-tenant
81
+ integrity hole: every agent published to one topic and the consumers trusted
82
+ the envelope's `tenant_id`, so one tenant's agent could write another's audit
83
+ trail. Pub/Sub attaches no publisher identity, so no consumer-side check can
84
+ close it — per-tenant topics make publish IAM the boundary instead."""
85
+
86
+ dlq_topic: str = ""
87
+ """Defaults to agent.invoke.<slug>.dlq when unset."""
88
+
89
+ # --- pull consumption ---
90
+ invoke_subscription: str = ""
91
+ """The subscription this process pulls invocations from. EMPTY MEANS DO NOT
92
+ CONSUME, which is the local-development path: the runtime is driven through
93
+ POST /invoke and the push routes instead."""
94
+
95
+ resume_subscription: str = ""
96
+ """Where a paused run's answer arrives. A tool confirmation pauses plain
97
+ AGENTS too, not only workflows, so every target has one."""
98
+
99
+ # --- push auth (unset locally) ---
100
+ push_transport: str = "auto"
101
+ """Which push transport the runtime receives on: auto or gcp. `auto`
102
+ means gcp — unlike the broker there is no value whose presence implies a
103
+ cloud, so this is named rather than inferred."""
104
+
105
+ push_sa_email: str = ""
106
+ expected_audience: str = ""
107
+ """LOCAL DEVELOPMENT ONLY now. In a cluster, invocations arrive by pull and
108
+ there is no audience to verify — which is precisely the failure class pull
109
+ removed: an EXPECTED_AUDIENCE that had to change in the same move a
110
+ subscription repointed, or every invocation 403s and retries forever."""
111
+
112
+ # --- internal services ---
113
+ ledger_service_url: str = ""
114
+ observability_service_url: str = ""
115
+
116
+ actions_service_url: str = ""
117
+ """Where a tenant's autonomy rules live. Empty means the manifest decides
118
+ on its own — which is safe, because a manifest can only be less permissive
119
+ than a grant somebody added on top of it."""
120
+
121
+ # --- model ---
122
+ google_cloud_project: str = ""
123
+ google_cloud_location: str = ""
124
+
125
+ agentkit_env: str = ""
126
+ """Which environment this runtime is in. `local` unlocks development-only
127
+ behaviour; anything else, INCLUDING EMPTY, does not.
128
+
129
+ Empty is the safe default on purpose: a pod that never sets this is a
130
+ production pod, so forgetting the variable refuses the unsafe path rather
131
+ than enabling it. `AGENTKIT_` is a reserved env prefix, so a tenant cannot
132
+ set this through `env:` in their elan.yaml.
133
+ """
134
+
135
+ model_override: str = ""
136
+ """LOCAL DEV ONLY, and REFUSED unless `agentkit_env == "local"`.
137
+
138
+ Substitutes the manifest's model so a model-availability problem can be
139
+ diagnosed without editing an agent. It is not merely discouraged in a
140
+ deployment — it is a hole. A tenant's elan.yaml may set `env:`, and the
141
+ deploy-time cross-check asserts `MANIFEST.model` is a member of
142
+ `llm.models`; MODEL_OVERRIDE substitutes the model AFTER that check has
143
+ passed. So the console shows one model, the agent runs another, and the
144
+ allowlist the tenant agreed to is a statement about a value nothing uses.
145
+
146
+ MODEL_OVERRIDE is also in RESERVED_ENV_NAMES, which is the deploy-time and
147
+ admission-time half of the same rule. This is the runtime half, and it is
148
+ the one that still holds if a pod reaches the cluster by some other path.
149
+ """
150
+
151
+ @property
152
+ def slugs(self) -> list[str]:
153
+ """Every target this process runs, in declaration order.
154
+
155
+ ORDER IS PRESERVED rather than sorted, because it is the order a group
156
+ was declared in and a reader comparing a log line to their elan.yaml
157
+ should not have to re-sort it in their head.
158
+
159
+ BOTH SET AND DISAGREEING IS AN ERROR, not a precedence rule. A pod with
160
+ `TARGET_SLUG=a` and `TARGET_SLUGS=b,c` has been rendered by two
161
+ different code paths that believe different things, and quietly
162
+ preferring one runs an agent nobody asked for while the other is never
163
+ consumed — its queue growing with nothing reporting it.
164
+ """
165
+ listed = [s.strip() for s in self.target_slugs.split(",") if s.strip()]
166
+ single = self.target_slug.strip()
167
+ if listed and single and [single] != listed:
168
+ raise ValueError(
169
+ f"TARGET_SLUG is {single!r} but TARGET_SLUGS is "
170
+ f"{', '.join(listed)!r}. Set one or the other."
171
+ )
172
+ if listed:
173
+ duplicates = {s for s in listed if listed.count(s) > 1}
174
+ if duplicates:
175
+ # TWO ENGINES ON ONE SUBSCRIPTION would each lease a share of
176
+ # the same messages, so half an agent's invocations would be
177
+ # run by its own duplicate — which looks like nothing wrong.
178
+ raise ValueError(
179
+ f"TARGET_SLUGS names {', '.join(sorted(duplicates))} more "
180
+ "than once."
181
+ )
182
+ return listed
183
+ return [single] if single else []
184
+
185
+
186
+ @lru_cache
187
+ def get_settings() -> Settings:
188
+ return Settings()