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.
- agentkit_runtime/__init__.py +0 -0
- agentkit_runtime/builder.py +448 -0
- agentkit_runtime/concurrency.py +46 -0
- agentkit_runtime/config.py +188 -0
- agentkit_runtime/consumer.py +151 -0
- agentkit_runtime/engine.py +1547 -0
- agentkit_runtime/fleet.py +168 -0
- agentkit_runtime/main.py +418 -0
- agentkit_runtime/manifest_export.py +143 -0
- agentkit_runtime/plugins.py +373 -0
- agentkit_runtime/pubsub_push.py +59 -0
- agentkit_runtime/registry.py +163 -0
- agentkit_runtime/schema_export.py +129 -0
- agentkit_runtime/tenant_plugins.py +200 -0
- elan_agentkit_runtime-0.1.0.dist-info/METADATA +49 -0
- elan_agentkit_runtime-0.1.0.dist-info/RECORD +18 -0
- elan_agentkit_runtime-0.1.0.dist-info/WHEEL +4 -0
- elan_agentkit_runtime-0.1.0.dist-info/licenses/LICENSE +202 -0
|
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()
|