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.
- agentkit_core/__init__.py +85 -0
- agentkit_core/approvals.py +364 -0
- agentkit_core/artifact_service.py +184 -0
- agentkit_core/artifacts.py +33 -0
- agentkit_core/auth.py +423 -0
- agentkit_core/autonomy.py +524 -0
- agentkit_core/broker/__init__.py +298 -0
- agentkit_core/broker/gcp.py +562 -0
- agentkit_core/broker/memory.py +207 -0
- agentkit_core/comms.py +457 -0
- agentkit_core/context.py +105 -0
- agentkit_core/cors.py +51 -0
- agentkit_core/db.py +99 -0
- agentkit_core/deeplinks.py +210 -0
- agentkit_core/deploy_spec.py +32 -0
- agentkit_core/email.py +261 -0
- agentkit_core/envelopes.py +33 -0
- agentkit_core/errors.py +31 -0
- agentkit_core/identity.py +36 -0
- agentkit_core/leads.py +188 -0
- agentkit_core/ledger_api.py +281 -0
- agentkit_core/logs.py +114 -0
- agentkit_core/manifest.py +33 -0
- agentkit_core/mcp_app.py +373 -0
- agentkit_core/metering.py +126 -0
- agentkit_core/pricing.py +33 -0
- agentkit_core/providers.py +181 -0
- agentkit_core/push/__init__.py +106 -0
- agentkit_core/push/gcp.py +97 -0
- agentkit_core/run_options.py +156 -0
- agentkit_core/run_view.py +284 -0
- agentkit_core/serve.py +54 -0
- agentkit_core/service_auth.py +394 -0
- agentkit_core/skus.py +394 -0
- agentkit_core/surface.py +138 -0
- agentkit_core/templates/__init__.py +24 -0
- agentkit_core/tenant_events.py +164 -0
- agentkit_core/tickets.py +422 -0
- agentkit_core/transcript.py +221 -0
- elan_agentkit_core-0.1.0.dist-info/METADATA +66 -0
- elan_agentkit_core-0.1.0.dist-info/RECORD +43 -0
- elan_agentkit_core-0.1.0.dist-info/WHEEL +4 -0
- 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
|