elan-agentkit-core 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.
Files changed (43) hide show
  1. agentkit_core/__init__.py +85 -0
  2. agentkit_core/approvals.py +364 -0
  3. agentkit_core/artifact_service.py +184 -0
  4. agentkit_core/artifacts.py +33 -0
  5. agentkit_core/auth.py +423 -0
  6. agentkit_core/autonomy.py +524 -0
  7. agentkit_core/broker/__init__.py +298 -0
  8. agentkit_core/broker/gcp.py +562 -0
  9. agentkit_core/broker/memory.py +207 -0
  10. agentkit_core/comms.py +457 -0
  11. agentkit_core/context.py +105 -0
  12. agentkit_core/cors.py +51 -0
  13. agentkit_core/db.py +99 -0
  14. agentkit_core/deeplinks.py +210 -0
  15. agentkit_core/deploy_spec.py +32 -0
  16. agentkit_core/email.py +261 -0
  17. agentkit_core/envelopes.py +33 -0
  18. agentkit_core/errors.py +31 -0
  19. agentkit_core/identity.py +36 -0
  20. agentkit_core/leads.py +188 -0
  21. agentkit_core/ledger_api.py +281 -0
  22. agentkit_core/logs.py +114 -0
  23. agentkit_core/manifest.py +33 -0
  24. agentkit_core/mcp_app.py +373 -0
  25. agentkit_core/metering.py +126 -0
  26. agentkit_core/pricing.py +33 -0
  27. agentkit_core/providers.py +181 -0
  28. agentkit_core/push/__init__.py +106 -0
  29. agentkit_core/push/gcp.py +97 -0
  30. agentkit_core/run_options.py +156 -0
  31. agentkit_core/run_view.py +284 -0
  32. agentkit_core/serve.py +54 -0
  33. agentkit_core/service_auth.py +394 -0
  34. agentkit_core/skus.py +394 -0
  35. agentkit_core/surface.py +138 -0
  36. agentkit_core/templates/__init__.py +24 -0
  37. agentkit_core/tenant_events.py +164 -0
  38. agentkit_core/tickets.py +422 -0
  39. agentkit_core/transcript.py +221 -0
  40. elan_agentkit_core-0.1.0.dist-info/METADATA +66 -0
  41. elan_agentkit_core-0.1.0.dist-info/RECORD +43 -0
  42. elan_agentkit_core-0.1.0.dist-info/WHEEL +4 -0
  43. elan_agentkit_core-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,85 @@
1
+ """Shared contracts for the agent pipeline."""
2
+
3
+ from agentkit_core.autonomy import (
4
+ ANY_TOOL,
5
+ ANY_USER,
6
+ Decision,
7
+ StoredPolicy,
8
+ fingerprint,
9
+ resolve,
10
+ stored_shape,
11
+ )
12
+ from agentkit_core.context import (
13
+ bind_invocation,
14
+ current_invocation,
15
+ current_segment,
16
+ drain_signals,
17
+ emit_signal,
18
+ )
19
+ from agentkit_core.envelopes import (
20
+ AgentEventEnvelope,
21
+ InvocationEnvelope,
22
+ MessagePart,
23
+ ResumeEnvelope,
24
+ new_id,
25
+ )
26
+ from agentkit_core.errors import (
27
+ AgentkitError,
28
+ BadEnvelopeError,
29
+ InsufficientFundsError,
30
+ ManifestError,
31
+ SaturatedError,
32
+ )
33
+ from agentkit_core.manifest import (
34
+ SLUG_PATTERN,
35
+ ActionPolicy,
36
+ AgentManifest,
37
+ BaseTargetManifest,
38
+ Billing,
39
+ Limits,
40
+ NodeOverrides,
41
+ Rule,
42
+ TargetManifest,
43
+ Tier,
44
+ ToolBinding,
45
+ ToolPhrasing,
46
+ WorkflowManifest,
47
+ )
48
+
49
+ __all__ = [
50
+ "ANY_TOOL",
51
+ "ANY_USER",
52
+ "SLUG_PATTERN",
53
+ "ActionPolicy",
54
+ "AgentEventEnvelope",
55
+ "AgentManifest",
56
+ "AgentkitError",
57
+ "BadEnvelopeError",
58
+ "BaseTargetManifest",
59
+ "Billing",
60
+ "Decision",
61
+ "InsufficientFundsError",
62
+ "InvocationEnvelope",
63
+ "Limits",
64
+ "ManifestError",
65
+ "MessagePart",
66
+ "NodeOverrides",
67
+ "ResumeEnvelope",
68
+ "Rule",
69
+ "SaturatedError",
70
+ "StoredPolicy",
71
+ "Tier",
72
+ "bind_invocation",
73
+ "current_invocation",
74
+ "current_segment",
75
+ "drain_signals",
76
+ "emit_signal",
77
+ "fingerprint",
78
+ "TargetManifest",
79
+ "ToolBinding",
80
+ "ToolPhrasing",
81
+ "WorkflowManifest",
82
+ "new_id",
83
+ "resolve",
84
+ "stored_shape",
85
+ ]
@@ -0,0 +1,364 @@
1
+ """An approval a developer declares: who answers it, what they fill in, and
2
+ what happens when they do (ADR-0144).
3
+
4
+ WHY THIS IS NOT A CONFIRMATION, which is the whole design. ADK resolves every
5
+ outstanding confirmation with the NEXT USER MESSAGE. That is harmless when the
6
+ person deciding is the person typing — they click, and carry on. It is fatal
7
+ when the decider is somebody else: the request sits there while the end user,
8
+ who cannot answer it, sends the message that throws it away. Measured on a real
9
+ conversation, where "ok" cancelled a refund the manager approved three minutes
10
+ later.
11
+
12
+ A LONG-RUNNING CALL SURVIVES THAT, and is the mechanism here. `is_long_running`
13
+ makes ADK record the call id and skip building an automatic response, so the
14
+ turn pauses on a call nothing will resolve until somebody answers it — however
15
+ much the conversation moves on meanwhile.
16
+
17
+ WHAT A CONFIRMATION GAVE FOR FREE AND THIS HAS TO SUPPLY. A confirmation
18
+ RE-RUNS its tool body with `confirmed=True`, so the tool does the work itself. A
19
+ long-running tool does not re-run: its result is injected as the function
20
+ response and the model reads it. So the work goes in `on_approval` / `on_deny`,
21
+ which the platform invokes when the decision arrives and whose return value
22
+ becomes the tool's return value. No model discipline, and no tool call
23
+ synthesised that ADK never asked for.
24
+
25
+ READ THE CONSTRAINT ON A CALLBACK BEFORE WRITING ONE: `ApprovalRequest` says
26
+ what it gets and `on_approval`'s own docstring says what it cannot reach.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from collections.abc import Callable
33
+ from dataclasses import dataclass
34
+ from typing import Any, Literal
35
+
36
+ from agentkit_core.manifest import WorkflowInput
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ #: One field on an approval's form.
41
+ #:
42
+ #: THE SAME MODEL A WORKFLOW'S LAUNCH FORM USES, aliased rather than copied.
43
+ #: It already carries the seven types, enum options, a default, requiredness,
44
+ #: help text and the validators — and this repo's rule for that list is to
45
+ #: write it once, so one renderer serves both and neither can drift from it.
46
+ ApprovalField = WorkflowInput
47
+
48
+ #: Who is expected to answer.
49
+ #:
50
+ #: `admin` is somebody holding the tenant's admin role; `end_user` is the person
51
+ #: the run belongs to, answered through whatever product that run came from.
52
+ #: Named for the check performed rather than for the organisation it stands
53
+ #: for — there is no separate approver role on this platform.
54
+ Asks = Literal["admin", "end_user"]
55
+
56
+ #: The `status` on the INITIAL result ADK hands straight back to the model.
57
+ #:
58
+ #: A MARKER AS WELL AS A WORD. The engine's redelivery guard asks "has this
59
+ #: call been answered already", and under ADK's long-running contract the
60
+ #: answer is always yes — the initial result IS a `FunctionResponse` against
61
+ #: the same id. So the guard tells the two apart by this value, which is why
62
+ #: a tool's own `pending` fields cannot overwrite it.
63
+ AWAITING = "awaiting_approval"
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class ApprovalRequest:
68
+ """Everything a callback is given, and the only thing it is given.
69
+
70
+ ONE OBJECT RATHER THAN A SIGNATURE, so adding a fact later does not break
71
+ every callback anybody has written.
72
+ """
73
+
74
+ call_id: str
75
+ tool: str
76
+ approved: bool
77
+ answers: dict[str, Any]
78
+ """What the approver filled in, coerced against the declared fields."""
79
+
80
+ decided_by: str
81
+ note: str
82
+ tool_args: dict[str, Any]
83
+ """What the model called the tool with, which is what was approved."""
84
+
85
+ state: dict[str, Any]
86
+ """The conversation's session state, read when the decision is applied.
87
+
88
+ THE ONLY WAY A CALLBACK SEES STATE, and it is read BY THE ENGINE rather
89
+ than by the callback: `ToolContext.state` belongs to an invocation context,
90
+ and none exists out here. The engine already loads the session to check
91
+ this decision is not a redelivery, so it hands over what it read.
92
+
93
+ READ-ONLY, AND A SNAPSHOT. Writing to it does nothing — ADK applies state
94
+ through an event's delta and there is no event here. If the work needs to
95
+ be recorded in the conversation, return it: the return value becomes the
96
+ tool's result and the model sees it.
97
+
98
+ AT DECISION TIME, NOT AT REQUEST TIME. An earlier design captured it in the
99
+ tool body and could not work at all: ADK yields the model-response event —
100
+ which is where the pause is detected and the request filed — BEFORE it runs
101
+ the body, so anything captured there was always too late.
102
+ """
103
+
104
+ invocation_id: str
105
+ session_id: str
106
+ tenant_id: str
107
+ project_id: str
108
+ user_id: str
109
+ target_slug: str
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class ApprovalContext:
114
+ """What `describe` is given, when the request is being filed.
115
+
116
+ ONE OBJECT, like `ApprovalRequest`, so adding a fact later does not break
117
+ every `describe` anybody has written.
118
+ """
119
+
120
+ tool: str
121
+ tool_args: dict[str, Any]
122
+ """What the model called the tool with."""
123
+
124
+ state: dict[str, Any]
125
+ """The conversation's session state, read by the engine as it files this.
126
+
127
+ THE ANSWER TO "WHAT AM I APPROVING", and it has to come from here rather
128
+ than from the model. The arguments on a card are the model's account of
129
+ what it wants; this is the record itself. An approver shown a count the
130
+ model asserted is approving the assertion, not the thing.
131
+
132
+ IT IS READ BEFORE THE TOOL BODY RUNS, which is why this is a property of
133
+ the ENGINE and not something a tool can do for itself: ADK files the
134
+ request first and calls the body afterwards.
135
+ """
136
+
137
+
138
+ class ApprovalTool:
139
+ """A tool whose call files an approval and waits for somebody to answer it.
140
+
141
+ NAMED WITHOUT THE BRAND, DELIBERATELY. These packages are `agentkit_*` and
142
+ carry no project-specific value — this repo is also the starter template
143
+ for other platforms, and `tests/test_template.py` enforces it by scanning
144
+ source. A product that wants its own name on this aliases it in its SDK,
145
+ which is where the brand already lives.
146
+
147
+ Built on `BaseTool` with `is_long_running=True` and NOTHING ELSE, which is
148
+ worth saying because the obvious base classes are both wrong:
149
+
150
+ `LongRunningFunctionTool`/`FunctionTool` derive the name from a callable and
151
+ — the part that matters — their `run_async` returns a TRUTHY error dict when
152
+ the model omits an argument. ADK then builds a response while the id is
153
+ already marked long-running, so a model's typo would file an approval nobody
154
+ could ever clear.
155
+
156
+ `_defers_response` has the same skip-on-empty behaviour but does NOT mark
157
+ the call on the event, so the run would report `finished` on a call nobody
158
+ answered. ADK's own comment says it is internal.
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ *,
164
+ name: str,
165
+ description: str,
166
+ asks: Asks = "end_user",
167
+ fields: list[ApprovalField] | None = None,
168
+ args: dict[str, Any] | None = None,
169
+ title: str = "",
170
+ body: str = "",
171
+ labels: list[str] | None = None,
172
+ pending: dict[str, Any] | None = None,
173
+ describe: Callable[[ApprovalContext], str] | None = None,
174
+ on_approval: Callable[[ApprovalRequest], Any] | None = None,
175
+ on_deny: Callable[[ApprovalRequest], Any] | None = None,
176
+ ) -> None:
177
+ from google.adk.tools.base_tool import BaseTool # noqa: PLC0415
178
+
179
+ # LAZILY, LIKE `builder.py` DOES ITS MCP IMPORTS. The module must stay
180
+ # importable where ADK is not installed — a test fixture, a tool that
181
+ # only validates a manifest — and a hard import at module scope would
182
+ # make the whole of `agentkit_core.approvals` unimportable there.
183
+ class _Tool(BaseTool):
184
+ def __init__(inner) -> None: # noqa: N805
185
+ super().__init__(name=name, description=description,
186
+ is_long_running=True)
187
+
188
+ def _get_declaration(inner): # noqa: N805
189
+ return self._declaration()
190
+
191
+ async def run_async(inner, *, args: dict, tool_context): # noqa: N805
192
+ return self._file(args, tool_context)
193
+
194
+ self.name = name
195
+ self.description = description
196
+ self.asks: Asks = asks
197
+ self.fields = list(fields or [])
198
+ self.args = dict(args or {})
199
+ self.title = title or name.replace("_", " ").capitalize()
200
+ self.body = body
201
+ self.labels = list(labels or [])
202
+ #: Extra fields on the INITIAL result the model is handed immediately.
203
+ #: Merged over the defaults, so a tool can tell the model what to say
204
+ #: while the request is outstanding.
205
+ self.pending = dict(pending or {})
206
+ self.describe = describe
207
+ self.on_approval = on_approval
208
+ self.on_deny = on_deny
209
+ self.adk = _Tool()
210
+ # THE BACK-REFERENCE THE WALKER FOLLOWS. What a built agent holds is
211
+ # the ADK tool; what answers a decision is this object, and nothing
212
+ # else connects them.
213
+ self.adk._approval_tool = self
214
+
215
+ # -- what the model sees ------------------------------------------------
216
+
217
+ def _declaration(self):
218
+ from google.genai import types # noqa: PLC0415
219
+
220
+ properties = {
221
+ key: types.Schema(type=types.Type.STRING, description=str(hint))
222
+ for key, hint in self.args.items()
223
+ }
224
+ return types.FunctionDeclaration(
225
+ name=self.name,
226
+ description=self.description,
227
+ parameters=types.Schema(type=types.Type.OBJECT,
228
+ properties=properties,
229
+ required=list(properties)),
230
+ )
231
+
232
+ # -- what the store records --------------------------------------------
233
+
234
+ def request_spec(self, state: dict[str, Any] | None = None,
235
+ args: dict[str, Any] | None = None) -> dict[str, Any]:
236
+ """The approval this tool raises, as the store should record it.
237
+
238
+ EVERY FIELD HERE IS A PROPERTY OF THE TOOL, not of the call — which is
239
+ why the engine reads it from the instance by name rather than having
240
+ the body smuggle it out through a contextvar. The first version did
241
+ that, and it failed in exactly the way that argues against it: ADK runs
242
+ a tool body where the engine's context does not reach, so the request
243
+ arrived empty and silently.
244
+
245
+ What IS per-call — the arguments the model passed — the action already
246
+ carries. `state` and `args` are passed only so `describe` can turn them
247
+ into the card's body; everything else here is fixed at construction.
248
+ """
249
+ return {
250
+ "kind": "approval",
251
+ "asks": self.asks,
252
+ "title": self.title,
253
+ "body": self._describe(state or {}, args or {}),
254
+ "labels": list(self.labels),
255
+ "fields": [f.model_dump(mode="json") for f in self.fields],
256
+ }
257
+
258
+ def _describe(self, state: dict[str, Any], args: dict[str, Any]) -> str:
259
+ """What this request says it is, which may depend on the conversation.
260
+
261
+ A STATIC `body` IS OFTEN NOT ENOUGH. "Publish the logbook" does not
262
+ tell an approver whether they are publishing three entries or three
263
+ hundred, and the model's own arguments cannot be the answer: those are
264
+ its account of what it wants, not the record. So `describe` is handed
265
+ the session state the engine read and returns the sentence.
266
+
267
+ A `describe` THAT RAISES FALLS BACK TO `body` rather than leaving the
268
+ turn paused on a request nobody can see. Failing to word a card is not
269
+ a reason to lose the approval.
270
+ """
271
+ if self.describe is None:
272
+ return self.body
273
+ try:
274
+ said = self.describe(ApprovalContext(
275
+ tool=self.name, tool_args=dict(args), state=dict(state)))
276
+ except Exception:
277
+ logger.exception(
278
+ "describe() failed for %s; falling back to its static body",
279
+ self.name)
280
+ return self.body
281
+ return str(said) if said else self.body
282
+
283
+ # -- the body -----------------------------------------------------------
284
+
285
+ def _file(self, args: dict[str, Any], tool_context: Any) -> dict[str, Any]:
286
+ """The INITIAL RESULT, which is ADK's documented long-running contract.
287
+
288
+ A long-running tool returns something immediately; ADK answers the
289
+ call with it so the model can speak, and a later `FunctionResponse`
290
+ carrying the same id delivers the real outcome. The call stays on
291
+ `long_running_tool_ids` throughout, which is what keeps it findable.
292
+
293
+ RETURNING NOTHING WAS THE FIRST VERSION AND IT COST A SILENCE. ADK
294
+ skips building the automatic response only on a falsy return, so the
295
+ turn suspended with the model having spent its whole output on the
296
+ function call — a user asked to publish the logbook and saw nothing at
297
+ all until somebody approved it, minutes later and three messages down.
298
+
299
+ IT MUST STAY TRUTHY. An empty dict is falsy, so a `pending` that
300
+ collapsed to `{}` would silently restore exactly that behaviour.
301
+
302
+ AND NOTHING IS CAPTURED HERE. ADK yields the model-response event —
303
+ where the engine sees the call and files the request — BEFORE calling
304
+ this, so anything recorded here arrives after the request is already
305
+ published. A callback reads session state through
306
+ `ApprovalRequest.state`, which the engine supplies at decision time.
307
+ """
308
+ # `pending` FIRST, so `status` cannot be overridden. It is not
309
+ # decoration: the engine's redelivery guard reads it to tell ADK's own
310
+ # initial result apart from a real decision, and a tool that replaced
311
+ # it would make its approval un-answerable.
312
+ return {
313
+ **self.pending,
314
+ "status": AWAITING,
315
+ # WHO IS BEING ASKED, in the model's own context, so it can say
316
+ # "the team" rather than inventing an audience.
317
+ "asks": self.asks,
318
+ "title": self.title,
319
+ }
320
+
321
+ # -- answering it -------------------------------------------------------
322
+
323
+ def callback(self, approved: bool) -> Callable[[ApprovalRequest], Any] | None:
324
+ return self.on_approval if approved else self.on_deny
325
+
326
+
327
+ def coerce_answers(fields: list[ApprovalField],
328
+ supplied: dict[str, Any]) -> dict[str, Any]:
329
+ """The approver's answers, typed against what was declared.
330
+
331
+ THE SAME COERCION A LAUNCH FORM GETS, and for the same reason: a form posts
332
+ strings, and a callback should receive the number it asked for rather than
333
+ `"3"`. An unknown key is refused rather than dropped — a field somebody
334
+ thought they were answering and nothing read is worse than an error.
335
+ """
336
+ from pydantic import TypeAdapter # noqa: PLC0415
337
+
338
+ from agentkit_core.manifest import PYTHON_TYPES # noqa: PLC0415
339
+
340
+ declared = {f.name: f for f in fields}
341
+ unknown = sorted(set(supplied) - set(declared))
342
+ if unknown:
343
+ raise ValueError(f"this approval has no field named {unknown[0]!r}")
344
+
345
+ answers: dict[str, Any] = {}
346
+ for name, item in declared.items():
347
+ if name not in supplied or supplied[name] in ("", None):
348
+ if item.required and item.default is None:
349
+ raise ValueError(f"{item.label!r} is required")
350
+ answers[name] = item.default
351
+ continue
352
+ value = supplied[name]
353
+ if item.type == "enum":
354
+ allowed = [o.value for o in item.options]
355
+ if value not in allowed:
356
+ raise ValueError(
357
+ f"{item.label!r} must be one of {', '.join(allowed)}")
358
+ answers[name] = value
359
+ continue
360
+ try:
361
+ answers[name] = TypeAdapter(PYTHON_TYPES[item.type]).validate_python(value)
362
+ except Exception as exc: # noqa: BLE001
363
+ raise ValueError(f"{item.label!r} is not a valid {item.type}") from exc
364
+ return answers
@@ -0,0 +1,184 @@
1
+ """`GcsArtifactService` widened with a project scope and an agent scope.
2
+
3
+ TWO METHODS OVERRIDDEN, AND THAT IS THE WHOLE EXTENSION. ADK composes every
4
+ artifact path through `_get_blob_prefix` — `save`, `load`, `delete`,
5
+ `list_versions`, `get_artifact_version` and `list_artifact_versions` all route
6
+ through it — so widening the layout is one override. `_list_artifact_keys` is
7
+ the single method that builds its prefixes inline instead, which is why it
8
+ needs a second.
9
+
10
+ WHY THIS LIVES IN `agentkit_core` AND NOT IN THE RUNTIME. Two callers need the
11
+ same layout: the agent pod, and rt-service signing a URL for an upload that
12
+ must land exactly where `load_artifact` will look for it. rt-service cannot
13
+ import the runtime — that would pull `agentkit-plugins` and `mcp` into a
14
+ service that needs neither — and a second copy of the layout is the duplication
15
+ ADR-0116 measured, where both copies pass their own tests and nothing compares
16
+ them. rt-service already pins `google-adk` to the runtime's range for exactly
17
+ this reason, stated in its pyproject: "so the two cannot resolve different
18
+ versions of the schema they share."
19
+
20
+ ADK IS IMPORTED AT MODULE SCOPE HERE, which is why this is its own module.
21
+ `agentkit_core.artifacts` carries the scope vocabulary with no ADK import at
22
+ all, so a manifest validator or a test fixture can read the rules without the
23
+ framework installed. Only something that actually stores an artifact imports
24
+ this file, and that something has ADK.
25
+
26
+ WHY A SUBCLASS AND NOT A PARALLEL SERVICE (ADR-0146). `Context.load_artifact`
27
+ passes the filename STRAIGHT THROUGH to whatever artifact service the Runner
28
+ holds, so a reserved filename prefix reaches every scope through
29
+ `tool_context.load_artifact("project:policy.pdf")` with no ADK change and no
30
+ private attribute access. ADK already reserves `user:` for exactly this; these
31
+ are two more of the same kind, not a mechanism beside it.
32
+
33
+ THE LAYOUT (ADR-0147). The bucket is one project's, so the project scope is
34
+ simply the bucket root outside every agent's folder:
35
+
36
+ _project/<name>/<version> project: every agent reads
37
+ <app_name>.shared/<name>/<version> agent: one agent reads
38
+ <app_name>/<user_id>/user/user:<n>/<version> user: ADK's own
39
+ <app_name>/<user_id>/<session_id>/<n>/<ver> (bare) ADK's own
40
+
41
+ THE PREFIX IS STRIPPED FROM THE STORED PATH FOR THE TWO NEW SCOPES, and kept
42
+ for ADK's. `user:` is part of the name ADK stores, so `_project/project:x/0`
43
+ would be consistent — and it would also be redundant, because the folder it
44
+ sits in already says the scope, and a human browsing the admin panel reads
45
+ these paths. `_list_artifact_keys` puts the prefix back with
46
+ `artifacts.qualify`, which is why that function exists as a documented inverse
47
+ rather than as a convenience.
48
+
49
+ WHAT THIS CLASS DOES NOT ENFORCE. Refusing an agent's write to a shared scope
50
+ here produces a READABLE error; it is not the boundary. The boundary is a
51
+ managed folder granting `objectViewer`, and a raw 403 surfacing from inside ADK
52
+ names nothing a developer can act on. `allow_shared_writes` is how the one
53
+ caller that legitimately writes them — rt-service, on behalf of a tenant
54
+ admin — opts in, so the pod and the admin route can share this one
55
+ implementation of the layout rather than keeping two.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ from typing import Any
61
+
62
+ from google.adk.artifacts import GcsArtifactService
63
+ from google.genai import types
64
+
65
+ from agentkit_core.artifacts import (
66
+ ARTIFACT_SCOPE_PREFIXES,
67
+ SHARED_SCOPES,
68
+ ArtifactNameRefused,
69
+ qualify,
70
+ validate_artifact_filename,
71
+ )
72
+ from agentkit_core.identity import ARTIFACT_PROJECT_FOLDER, ARTIFACT_SHARED_SUFFIX
73
+
74
+
75
+ class ScopedGcsArtifactService(GcsArtifactService):
76
+ """ADK's GCS artifact service plus the project and agent scopes."""
77
+
78
+ def __init__(self, bucket_name: str, *, allow_shared_writes: bool = False,
79
+ **kwargs: Any) -> None:
80
+ super().__init__(bucket_name, **kwargs)
81
+ self.allow_shared_writes = allow_shared_writes
82
+
83
+ # --- the layout ---------------------------------------------------------
84
+
85
+ def _get_blob_prefix(self, app_name: str, user_id: str, filename: str,
86
+ session_id: str | None = None) -> str:
87
+ """Where this artifact's versions live, for all four scopes.
88
+
89
+ VALIDATION HAPPENS HERE BECAUSE EVERY OPERATION PASSES THROUGH HERE.
90
+ ADK never applies `validate_path_segment` to a filename — deliberately,
91
+ since a filename may contain `/` — so traversal is ours to refuse, and
92
+ refusing it in the one place every path is composed means no caller can
93
+ reach the bucket around it.
94
+ """
95
+ scope, name = validate_artifact_filename(filename)
96
+ if scope == "project":
97
+ return f"{ARTIFACT_PROJECT_FOLDER}{name}"
98
+ if scope == "agent":
99
+ return f"{app_name}{ARTIFACT_SHARED_SUFFIX}/{name}"
100
+ # `user:` and bare filenames are ADK's own and are left entirely to it,
101
+ # original filename included — the `user:` prefix is part of the name it
102
+ # stores, and stripping it here would move every existing blob.
103
+ return super()._get_blob_prefix(app_name, user_id, filename, session_id)
104
+
105
+ def _list_artifact_keys(self, app_name: str, user_id: str,
106
+ session_id: str | None) -> list[str]:
107
+ """ADK's session and user keys, plus the two shared scopes.
108
+
109
+ THE PREFIXES ARE PUT BACK, exactly as ADK retains `user:` on the names
110
+ it returns — a caller that reads a name out of this list must be able
111
+ to pass it straight back to `load_artifact`, and a bare `policy.pdf`
112
+ would resolve to the session scope instead.
113
+
114
+ A LISTING WITH NO SESSION — AND NO USER — IS LEGITIMATE, AND DELEGATING
115
+ BLINDLY MADE IT A 500 (ADR-0159). The console's documents page asks for
116
+ the SHARED scopes, which belong to a project and an agent rather than
117
+ to anybody's conversation, so it sends neither. ADK's own
118
+ `_list_artifact_keys` cannot answer that: it validates `user_id`
119
+ unconditionally and validates `session_id` whenever it is not None, so
120
+ an empty string raises `session_id must not be empty` from inside a
121
+ path-segment check — four frames below anything naming a scope.
122
+
123
+ The shared scopes depend on NOTHING but `app_name`, which is why they
124
+ are listed outside that branch. Three readings, each meaningful:
125
+
126
+ user and session all four scopes
127
+ user, no session project, agent and that user's own
128
+ neither project and agent
129
+
130
+ `session_id or None` IS ADK'S OWN SPELLING OF "NO SESSION" rather than
131
+ a way around its validator: it checks `if session_id is not None` and
132
+ then lists the session scope only `if session_id`, so None is the value
133
+ that means absent and "" is the value that means malformed.
134
+ """
135
+ if session_id and not user_id:
136
+ # A SESSION BELONGS TO A USER, so this combination addresses
137
+ # nothing. Refused rather than quietly dropped: silently ignoring
138
+ # the session would return a listing that looks complete while
139
+ # omitting the very scope the caller asked about.
140
+ raise ArtifactNameRefused(
141
+ "a session artifact listing needs the user it belongs to; "
142
+ "pass both, or neither for the project and agent scopes")
143
+ filenames: set[str] = set()
144
+ if user_id:
145
+ filenames |= set(super()._list_artifact_keys(
146
+ app_name, user_id, session_id or None))
147
+ for scope, folder in (
148
+ ("project", ARTIFACT_PROJECT_FOLDER),
149
+ ("agent", f"{app_name}{ARTIFACT_SHARED_SUFFIX}/"),
150
+ ):
151
+ for blob in self.storage_client.list_blobs(self.bucket, prefix=folder):
152
+ # `<folder><name>/<version>` — the name may itself contain "/",
153
+ # so drop only the last segment, which is ADK's own rule.
154
+ tail = blob.name[len(folder):]
155
+ name = "/".join(tail.split("/")[:-1])
156
+ if name:
157
+ filenames.add(qualify(scope, name))
158
+ return sorted(filenames)
159
+
160
+ # --- the readable half of a boundary IAM enforces -----------------------
161
+
162
+ def _refuse_shared_write(self, filename: str, verb: str) -> None:
163
+ if self.allow_shared_writes:
164
+ return
165
+ scope, _ = validate_artifact_filename(filename)
166
+ if scope in SHARED_SCOPES:
167
+ raise ArtifactNameRefused(
168
+ f"an agent may read but not {verb} the {scope} scope "
169
+ f"({ARTIFACT_SCOPE_PREFIXES[scope]}…); it belongs to the tenant "
170
+ f"and is managed from the console. This is also enforced by IAM, "
171
+ f"which would refuse it with no explanation.")
172
+
173
+ def _save_artifact(self, app_name: str, user_id: str,
174
+ session_id: str | None, filename: str,
175
+ artifact: types.Part | dict[str, Any],
176
+ custom_metadata: dict[str, Any] | None = None) -> int:
177
+ self._refuse_shared_write(filename, "write")
178
+ return super()._save_artifact(app_name, user_id, session_id, filename,
179
+ artifact, custom_metadata)
180
+
181
+ def _delete_artifact(self, app_name: str, user_id: str,
182
+ session_id: str | None, filename: str) -> None:
183
+ self._refuse_shared_write(filename, "delete")
184
+ return super()._delete_artifact(app_name, user_id, session_id, filename)
@@ -0,0 +1,33 @@
1
+ """The four artifact scopes, and what a filename may say.
2
+
3
+ MOVED TO `elan_agentkit_contracts.artifacts` (ADR-0197), WHICH THIS RE-EXPORTS.
4
+ The scope vocabulary is a PREDICATE every client has to agree on (ADR-0116).
5
+ The SDK validates a filename before asking for an upload ticket and the CLI
6
+ does the same for `elan files put`, so a second copy of the rule would refuse
7
+ names the service accepts — and the reserved prefixes are how `project:` and
8
+ `agent:` reach a wider scope at all.
9
+
10
+ WHY ANYTHING MOVED AT ALL: `elan-sdk` and `elan-cli` are published to PyPI, and
11
+ a `[tool.uv.sources] … workspace = true` dependency on `agentkit-core` is
12
+ uv-local metadata that never reaches wheel METADATA — so a published wheel
13
+ declared a bare `Requires-Dist: agentkit-core` that pip resolves from an index
14
+ which has never seen it. Publishing `agentkit-core` instead would have shipped
15
+ the token verifiers, the ticket minter, the database layer and the broker to
16
+ every developer who installed the SDK.
17
+
18
+ THE SHIM EXISTS SO 247 IMPORT SITES DID NOT HAVE TO CHANGE. Every service, the
19
+ runtime, the plugins, the scripts and the test suite keep importing
20
+ `agentkit_core.artifacts`; only the SDK and the CLI were rewritten, because
21
+ they are the two packages that had to stop depending on `agentkit-core` at all.
22
+ Rewriting the rest would have put a branded module name in eight services and
23
+ meant exempting most of the repo from `tests/test_template.py`'s brand scan,
24
+ which is the guard that keeps this repo usable as a starter template.
25
+
26
+ THE RE-EXPORT HONOURS `__all__`, so a name defined over there and missing from
27
+ that list does not exist here — the quietest possible failure, an ImportError
28
+ at a call site nobody edited. `tests/test_contracts_package.py` walks both
29
+ sides and fails naming the gap.
30
+ """
31
+
32
+ from elan_agentkit_contracts.artifacts import * # noqa: F403
33
+ from elan_agentkit_contracts.artifacts import __all__ # noqa: F401