aipelago-sdk 0.6.1__tar.gz

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.
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: aipelago-sdk
3
+ Version: 0.6.1
4
+ Summary: The SDK agents are written against: @tool, @hook and the ToolContext a handler receives.
5
+ Author-email: Jiaan Liu <jiaanliux@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pydantic==2.12.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # aipelago-sdk
13
+
14
+ The author-facing API for Aipelago agents: `@tool`, `@hook`, and the `ToolContext`
15
+ the kernel injects into every handler.
16
+
17
+ The same API on every distribution of the kernel, so an agent written for one
18
+ runs on all of them. A distribution's developer CLI (for Ailands, `pip install
19
+ ailands`) depends on this package, so installing the CLI brings it in.
20
+
21
+ ```python
22
+ from aipelago.sdk import tool, ToolContext
23
+
24
+ @tool("weather.today")
25
+ async def today(ctx: ToolContext, args: dict) -> dict:
26
+ return {"forecast": "sun"}
27
+ ```
28
+
29
+ Installing this package needs no kernel: it exists so you can type-check and
30
+ unit-test an agent on your own machine.
@@ -0,0 +1,19 @@
1
+ # aipelago-sdk
2
+
3
+ The author-facing API for Aipelago agents: `@tool`, `@hook`, and the `ToolContext`
4
+ the kernel injects into every handler.
5
+
6
+ The same API on every distribution of the kernel, so an agent written for one
7
+ runs on all of them. A distribution's developer CLI (for Ailands, `pip install
8
+ ailands`) depends on this package, so installing the CLI brings it in.
9
+
10
+ ```python
11
+ from aipelago.sdk import tool, ToolContext
12
+
13
+ @tool("weather.today")
14
+ async def today(ctx: ToolContext, args: dict) -> dict:
15
+ return {"forecast": "sun"}
16
+ ```
17
+
18
+ Installing this package needs no kernel: it exists so you can type-check and
19
+ unit-test an agent on your own machine.
@@ -0,0 +1,36 @@
1
+ """Aipelago SDK — author-facing API.
2
+
3
+ What you import as an agent author. The kernel injects a `ToolContext`
4
+ into every tool handler; the SDK is the only surface authors should depend on.
5
+
6
+ It needs nothing else of the kernel, so it installs on its own as the
7
+ `aipelago-sdk` package: the one import every distribution's agents are written in.
8
+ """
9
+
10
+ from aipelago.sdk.context import (
11
+ AgentBuilderHandle,
12
+ AskReply,
13
+ EmbeddingHandle,
14
+ InboundAttachment,
15
+ SessionHandle,
16
+ SharesHandle,
17
+ ToolContext,
18
+ )
19
+ from aipelago.sdk.decorator import tool
20
+ from aipelago.sdk.hook import HookDecision, hook
21
+ from aipelago.sdk.principal import Principal, PrincipalStatus
22
+
23
+ __all__ = [
24
+ "AgentBuilderHandle",
25
+ "AskReply",
26
+ "EmbeddingHandle",
27
+ "HookDecision",
28
+ "InboundAttachment",
29
+ "Principal",
30
+ "PrincipalStatus",
31
+ "SessionHandle",
32
+ "SharesHandle",
33
+ "ToolContext",
34
+ "hook",
35
+ "tool",
36
+ ]
@@ -0,0 +1,800 @@
1
+ """`ToolContext` — the only object a tool handler may rely on.
2
+
3
+ The kernel constructs this per tool call and passes it as the first argument to
4
+ the handler. Crucially, it carries no raw DB connection, no provider client, no
5
+ `os` handle — every effect goes through a mediated method.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Protocol
15
+ from uuid import UUID
16
+
17
+ from aipelago.sdk.principal import Principal
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class AskReply:
22
+ """What came back from the person `ctx.ask` asked.
23
+
24
+ `text` is always the whole answer in prose. It is what a chat channel
25
+ produced, what the trajectory records, and what a yes/no decision is judged
26
+ on — every path can fill it, so every caller can read it.
27
+
28
+ `values` is the same answer keyed by field, and only a form filled in on a
29
+ surface that could draw one produces it. It is the half worth having when
30
+ there were several questions: without it the caller gets four answers glued
31
+ into one string and has to take them apart again, which is the parsing step
32
+ the form existed to remove. Empty when the answer arrived as plain text.
33
+ """
34
+
35
+ text: str
36
+ values: dict[str, list[str]] = field(default_factory=dict)
37
+
38
+
39
+ class SessionHandle(Protocol):
40
+ """Ephemeral per-session scratch, exposed as `ctx.session`."""
41
+
42
+ def get(self, key: str, default: Any = None) -> Any: ...
43
+ def set(self, key: str, value: Any) -> None: ...
44
+
45
+
46
+ class InboundAttachment(Protocol):
47
+ """One of the caller's attachments as `ctx.attachment()` returns it — bytes
48
+ plus enough to interpret them."""
49
+
50
+ @property
51
+ def media_type(self) -> str: ...
52
+ @property
53
+ def data(self) -> bytes: ...
54
+ @property
55
+ def filename(self) -> str: ...
56
+ @property
57
+ def id(self) -> UUID | None: ...
58
+
59
+
60
+ #: Ask the user a free-form question (optionally with suggested choices) and wait
61
+ #: for their reply over the active channel. Returns the reply text, or None if no
62
+ #: interactive channel is attached or the wait times out.
63
+ AskFn = Callable[[str, "list[str] | None"], Awaitable["str | None"]]
64
+
65
+ #: Fetch one of the caller's own attachments by id. Returns the attachment
66
+ #: (bytes + media_type + filename), or None when no attachment of this caller
67
+ #: has that id — "not yours" and "does not exist" look the same.
68
+ AttachmentFn = Callable[[str], Awaitable["InboundAttachment | None"]]
69
+
70
+ #: Answers `ctx.is_admin()`. Three-valued on purpose — see the method.
71
+ AdminFn = Callable[[], Awaitable["bool | None"]]
72
+ #: Behind `ctx.notify`: the notice body in, the receipt out.
73
+ NotifyFn = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
74
+
75
+
76
+ class _StateHandle(Protocol):
77
+ async def execute(self, query: str, *args: Any) -> str: ...
78
+ async def fetch(self, query: str, *args: Any) -> list[Any]: ...
79
+ async def fetchrow(self, query: str, *args: Any) -> Any: ...
80
+ async def fetchval(self, query: str, *args: Any) -> Any: ...
81
+
82
+
83
+ class CapabilityHandle:
84
+ """Capability invocation handle exposed via `ctx.capability`."""
85
+
86
+ def __init__(
87
+ self,
88
+ check: Callable[[str], Awaitable[bool]],
89
+ invoke: Callable[[str, dict[str, Any]], Awaitable[Any]],
90
+ ) -> None:
91
+ self._check = check
92
+ self._invoke = invoke
93
+
94
+ async def check(self, capability_id: str) -> bool:
95
+ return await self._check(capability_id)
96
+
97
+ async def invoke(self, capability_id: str, args: dict[str, Any]) -> Any:
98
+ return await self._invoke(capability_id, args)
99
+
100
+
101
+ class ChannelHandle:
102
+ """Channel send handle exposed via `ctx.channel`."""
103
+
104
+ def __init__(
105
+ self,
106
+ send: Callable[..., Awaitable[Any]],
107
+ ) -> None:
108
+ self._send = send
109
+
110
+ async def send(self, *, to: Any, message: str, via: str | None = None) -> Any:
111
+ return await self._send(to=to, message=message, via=via)
112
+
113
+
114
+ class BudgetHandle:
115
+ """Per-invocation budget control."""
116
+
117
+ def __init__(self, charge: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
118
+ self._charge = charge
119
+
120
+ async def charge(self, cost: dict[str, Any]) -> None:
121
+ await self._charge(cost)
122
+
123
+
124
+ class EmbeddingHandle:
125
+ """Provider-neutral embedding seam exposed to agent tools."""
126
+
127
+ def __init__(self, registry: Any) -> None:
128
+ self._registry = registry
129
+
130
+ def describe(self) -> dict[str, Any]:
131
+ if self._registry is None:
132
+ return {"available": False}
133
+ name = self._registry.select()
134
+ if name is None:
135
+ return {"available": False}
136
+ provider = self._registry.get(name)
137
+ metadata = dict(provider.meta)
138
+ describe = metadata.pop("describe", None)
139
+ if callable(describe):
140
+ metadata.update(describe())
141
+ return {
142
+ "available": True,
143
+ "provider": name,
144
+ "dimensions": metadata.pop("dimensions", metadata.pop("dim", None)),
145
+ "semantic": name != "hashing",
146
+ **metadata,
147
+ }
148
+
149
+ async def embed(self, texts: list[str], dimensions: int | None = None) -> list[list[float]]:
150
+ if self._registry is None:
151
+ raise RuntimeError("embedding backend unavailable")
152
+ name = self._registry.select()
153
+ if name is None:
154
+ raise RuntimeError("embedding backend unavailable")
155
+ return await self._registry.get(name).run(texts, dimensions)
156
+
157
+
158
+ class ScheduleHandle:
159
+ """Deferred/recurring self-scheduling exposed via `ctx.schedule`.
160
+
161
+ Lets a tool register a future persona invocation (a reminder, a recurring
162
+ job) that the kernel fires and delivers later — the real primitive behind
163
+ "remind me in 15 minutes". Absent (None) inside an autonomous scheduled run,
164
+ so a fired task cannot schedule more (no recursion)."""
165
+
166
+ def __init__(
167
+ self,
168
+ set_fn: Callable[..., Awaitable[Any]],
169
+ list_fn: Callable[[], Awaitable[Any]],
170
+ cancel_fn: Callable[[str], Awaitable[bool]],
171
+ ) -> None:
172
+ self._set = set_fn
173
+ self._list = list_fn
174
+ self._cancel = cancel_fn
175
+
176
+ async def set(
177
+ self, *, when: str, kind: str = "notify", message: str = "", label: str = ""
178
+ ) -> Any:
179
+ return await self._set(when=when, kind=kind, message=message, label=label)
180
+
181
+ async def list(self) -> Any:
182
+ return await self._list()
183
+
184
+ async def cancel(self, task_id: str) -> bool:
185
+ return await self._cancel(task_id)
186
+
187
+
188
+ class DispatchHandle:
189
+ """Handing work to a PEER agent, exposed via `ctx.dispatch`.
190
+
191
+ Not a call into the peer's code — the kernel starts the peer's OWN persona
192
+ on the brief, with its own prompt, model and tools. Handed over, the work is
193
+ the peer's: the person hears from it directly, and its answer — what it
194
+ said when it finished — comes back to the sender through `status`. Asked
195
+ (`hand_off(wait=True)`), the sender waits for that answer inside its own
196
+ turn, which pays for it.
197
+
198
+ Absent (None) when this run may not hand work to a peer — for example when
199
+ it acts for no person.
200
+
201
+ Also how a run that IS a handed-over task sends it back — see `decline`.
202
+ """
203
+
204
+ def __init__(
205
+ self,
206
+ hand_off_fn: Callable[..., Awaitable[Any]],
207
+ status_fn: Callable[[], Awaitable[Any]],
208
+ decline_fn: Callable[[str], Awaitable[None]] | None = None,
209
+ find_fn: Callable[[str], Awaitable[Any]] | None = None,
210
+ ) -> None:
211
+ self._hand_off = hand_off_fn
212
+ self._status = status_fn
213
+ self._decline = decline_fn
214
+ self._find = find_fn
215
+
216
+ async def hand_off(
217
+ self,
218
+ *,
219
+ to_agent: str,
220
+ brief: str,
221
+ label: str = "",
222
+ forward_user_turn: bool = True,
223
+ wait: bool = False,
224
+ ) -> Any:
225
+ """Returns a RECEIPT (which peer, which handoff id, its status).
226
+
227
+ `wait=True` asks rather than hands over: the receipt comes back once the
228
+ peer has answered, with its `answer`, and the peer's run spends against
229
+ this run's own turn. A peer that cannot start now, or is still working
230
+ when this turn's own limits run out, carries on as an ordinary handoff;
231
+ its answer is then read later through `status`.
232
+
233
+ `forward_user_turn` sends the person's own message and its attachments
234
+ along with the brief. On by default, and the caller can only turn it OFF
235
+ — it cannot choose which turn, edit the words, or substitute the files.
236
+ The runtime reads them off the message this run was actually given, so
237
+ the receiver's "what the person sent" really is that.
238
+ """
239
+ return await self._hand_off(
240
+ to_agent=to_agent,
241
+ brief=brief,
242
+ label=label,
243
+ forward_user_turn=forward_user_turn,
244
+ wait=wait,
245
+ )
246
+
247
+ async def status(self) -> Any:
248
+ """What this agent has handed off for this person lately, how it went,
249
+ and each peer's answer: what it said when it finished, or why not."""
250
+ return await self._status()
251
+
252
+ async def find(self, need: str) -> Any:
253
+ """Peers whose own words look like `need`, best first.
254
+
255
+ The prompt names only some of the person's other agents; this searches
256
+ all of them, and finds none the person could not use.
257
+ """
258
+ if self._find is None:
259
+ return []
260
+ return await self._find(need)
261
+
262
+ @property
263
+ def can_decline(self) -> bool:
264
+ """True only inside a run that IS a handed-over piece of work.
265
+
266
+ There is nothing to decline anywhere else, and a tool that accepted the
267
+ word regardless would let a model believe it had refused something.
268
+ """
269
+ return self._decline is not None
270
+
271
+ async def decline(self, reason: str) -> None:
272
+ """Send this brief back: not work this agent does, and here is why.
273
+
274
+ Ends the handoff as `declined` — a terminal state that is neither
275
+ success nor failure. The kernel records the reason, tells the person, and
276
+ makes it visible to the sender through `status`. Declining in prose alone
277
+ would settle the handoff as `done`, which is the sender being told the
278
+ work happened.
279
+ """
280
+ if self._decline is None:
281
+ raise ValueError("nothing to decline: this run is not a handed-over task")
282
+ await self._decline(reason)
283
+
284
+
285
+ _Call = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
286
+
287
+
288
+ class MemoryHandle:
289
+ """What this agent remembers about the person it acts for, via `ctx.memory`:
290
+ facts and standing rules that change how it helps, one sentence under a key.
291
+ Kept by the kernel per (agent, person), shown to the person in their settings.
292
+
293
+ Every method acts for the principal this context was built for; there is no
294
+ argument that names another, and no agent can read another agent's memory.
295
+ Returns plain dicts; a mistake the caller can fix raises `ValueError`. None
296
+ for system principals."""
297
+
298
+ def __init__(self, call: _Call) -> None:
299
+ self._call = call
300
+
301
+ async def list(self) -> dict[str, Any]:
302
+ return await self._call("list", {})
303
+
304
+ async def recall(self, query: str) -> dict[str, Any]:
305
+ return await self._call("recall", {"query": query})
306
+
307
+ async def save(self, key: str, content: str) -> dict[str, Any]:
308
+ """Save or overwrite the memory under `key`."""
309
+ return await self._call("save", {"key": key, "content": content})
310
+
311
+ async def forget(self, key: str) -> dict[str, Any]:
312
+ return await self._call("forget", {"key": key})
313
+
314
+ async def history(self, id: str | None = None, *, limit: int = 30) -> dict[str, Any]:
315
+ return await self._call("history", {"id": id, "limit": limit})
316
+
317
+ async def undo(self, change_id: str) -> dict[str, Any]:
318
+ return await self._call("undo", {"change_id": change_id})
319
+
320
+
321
+ class TodoHandle:
322
+ """The person's to-dos with this agent, via `ctx.todos`: a title, a note, an
323
+ optional `due` date (2026-09-25) with `due_time` (18:30), `remind` and
324
+ `repeat` (daily/weekly/monthly/yearly), done or not, and optionally the
325
+ `item_id` it is about with `on_done={label, value}` written to that item when
326
+ it is finished. The person sees and edits them. Same rules as `MemoryHandle`."""
327
+
328
+ def __init__(self, call: _Call) -> None:
329
+ self._call = call
330
+
331
+ async def list(self, status: str = "open", query: str = "") -> dict[str, Any]:
332
+ """`status`: open, done or all."""
333
+ return await self._call("list", {"status": status, "query": query})
334
+
335
+ async def add(self, title: str, **fields: Any) -> dict[str, Any]:
336
+ return await self._call("add", {"title": title, **fields})
337
+
338
+ async def update(self, id: str, **changes: Any) -> dict[str, Any]:
339
+ return await self._call("update", {"id": id, **changes})
340
+
341
+ async def done(self, id: str, done: bool = True) -> dict[str, Any]:
342
+ return await self._call("done", {"id": id, "done": done})
343
+
344
+ async def delete(self, id: str) -> dict[str, Any]:
345
+ return await self._call("delete", {"id": id})
346
+
347
+ async def history(self, id: str | None = None, *, limit: int = 30) -> dict[str, Any]:
348
+ return await self._call("history", {"id": id, "limit": limit})
349
+
350
+ async def undo(self, change_id: str) -> dict[str, Any]:
351
+ return await self._call("undo", {"change_id": change_id})
352
+
353
+
354
+ class ItemHandle:
355
+ """What the person keeps with this agent to look up, via `ctx.items`: each item
356
+ a title, a free-text `category`, and ordered `fields` —
357
+ `[{label, value, kind}]` with kind text, longtext, date, secret, url, phone or
358
+ number (`remind: true` on a date reminds the person). No schema to declare.
359
+ `update` merges fields by label, `remove` drops labels, and `note` adds a line
360
+ of what was done to the item's history. Same rules as `MemoryHandle`."""
361
+
362
+ def __init__(self, call: _Call) -> None:
363
+ self._call = call
364
+
365
+ async def list(self, category: str = "", query: str = "") -> dict[str, Any]:
366
+ return await self._call("list", {"category": category, "query": query})
367
+
368
+ async def counts(self, category: str = "", query: str = "") -> dict[str, Any]:
369
+ """How many items match, in all and per category — exact, unlike a list,
370
+ which stops at 500."""
371
+ return await self._call("counts", {"category": category, "query": query})
372
+
373
+ async def get(self, id: str) -> dict[str, Any]:
374
+ return await self._call("get", {"id": id})
375
+
376
+ async def save(
377
+ self, title: str, category: str = "", fields: Any = None, note: str = ""
378
+ ) -> dict[str, Any]:
379
+ return await self._call(
380
+ "save", {"title": title, "category": category, "fields": fields or [], "note": note}
381
+ )
382
+
383
+ async def update(self, id: str, **changes: Any) -> dict[str, Any]:
384
+ return await self._call("update", {"id": id, **changes})
385
+
386
+ async def delete(self, id: str) -> dict[str, Any]:
387
+ return await self._call("delete", {"id": id})
388
+
389
+ async def history(self, id: str | None = None, *, limit: int = 30) -> dict[str, Any]:
390
+ return await self._call("history", {"id": id, "limit": limit})
391
+
392
+ async def undo(self, change_id: str) -> dict[str, Any]:
393
+ return await self._call("undo", {"change_id": change_id})
394
+
395
+
396
+ class SharesHandle:
397
+ """The shares of this agent that the person it acts for takes part in, via
398
+ `ctx.shares`. None outside a person's principal.
399
+
400
+ - `list()` — each share: `{id, kind, title, ref, role, principal_ref,
401
+ unread, you, members: [{ref, name, role}]}`. Members are pseudonyms (`ref`)
402
+ stable for this agent; never kernel ids or addresses.
403
+ - `principal(share_id)` — the share's `principal_ref`: what your SQL writes
404
+ into `principal_id` to put a row in the share. Row-level security decides
405
+ whether the write is allowed (a viewer's is not); knowing it grants nothing.
406
+ - `activity(share_id, kind, ref="", summary=None)` — record what happened,
407
+ as the person this run acts for; every member sees it.
408
+ - `propose(kind=, title=, ref=, invite_emails=, announce=True)` — ask the
409
+ person's app to open its share panel with this filled in. Nothing is
410
+ shared until they confirm there. `announce=False` only checks it and
411
+ returns the proposal, for a caller that shows it itself."""
412
+
413
+ def __init__(
414
+ self,
415
+ list_fn: Callable[[], Awaitable[list[dict[str, Any]]]],
416
+ principal_fn: Callable[[str], Awaitable[str]],
417
+ activity_fn: Callable[[str, str, str, dict[str, Any]], Awaitable[int]],
418
+ propose_fn: Callable[[dict[str, Any], bool], Awaitable[dict[str, Any]]],
419
+ ) -> None:
420
+ self._list = list_fn
421
+ self._principal = principal_fn
422
+ self._activity = activity_fn
423
+ self._propose = propose_fn
424
+
425
+ async def list(self) -> list[dict[str, Any]]:
426
+ return await self._list()
427
+
428
+ async def principal(self, share_id: str) -> str:
429
+ return await self._principal(share_id)
430
+
431
+ async def activity(
432
+ self, share_id: str, kind: str, ref: str = "", summary: dict[str, Any] | None = None
433
+ ) -> int:
434
+ return await self._activity(share_id, kind, ref, summary or {})
435
+
436
+ async def propose(
437
+ self,
438
+ *,
439
+ kind: str = "thing",
440
+ title: str,
441
+ ref: str = "",
442
+ invite_emails: list[str] | None = None,
443
+ announce: bool = True,
444
+ ) -> dict[str, Any]:
445
+ return await self._propose(
446
+ {"kind": kind, "title": title, "ref": ref, "invite_emails": invite_emails or []},
447
+ announce,
448
+ )
449
+
450
+
451
+ class AgentBuilderHandle:
452
+ """Narrow personal-Agent composition seam exposed via `ctx.agent_builder`.
453
+
454
+ It never accepts a raw manifest or source code: it takes a constrained spec,
455
+ checks it against the tools and capabilities that exist, and makes an
456
+ ordinary prompt-only agent from it.
457
+
458
+ The person has at most one **draft**: a spec not yet published, which they
459
+ can try as an agent and which their own builder edits too. `draft` reads it,
460
+ `save_draft` replaces it, `draft_presentation` reads or replaces how it
461
+ looks to them.
462
+ """
463
+
464
+ def __init__(
465
+ self,
466
+ catalog_fn: Callable[[], Awaitable[dict[str, Any]]],
467
+ preview_fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]],
468
+ publish_fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]],
469
+ list_fn: Callable[[], Awaitable[list[dict[str, Any]]]],
470
+ archive_fn: Callable[[str], Awaitable[None]],
471
+ draft_fn: Callable[[], Awaitable[dict[str, Any] | None]],
472
+ save_draft_fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]],
473
+ draft_presentation_fn: Callable[[dict[str, Any] | None], Awaitable[dict[str, Any] | None]],
474
+ ) -> None:
475
+ self._catalog = catalog_fn
476
+ self._preview = preview_fn
477
+ self._publish = publish_fn
478
+ self._list = list_fn
479
+ self._archive = archive_fn
480
+ self._draft = draft_fn
481
+ self._save_draft = save_draft_fn
482
+ self._draft_presentation = draft_presentation_fn
483
+
484
+ async def catalog(self) -> dict[str, Any]:
485
+ return await self._catalog()
486
+
487
+ async def preview(self, spec: dict[str, Any]) -> dict[str, Any]:
488
+ return await self._preview(spec)
489
+
490
+ async def publish(self, spec: dict[str, Any]) -> dict[str, Any]:
491
+ return await self._publish(spec)
492
+
493
+ async def list(self) -> list[dict[str, Any]]:
494
+ return await self._list()
495
+
496
+ async def archive(self, agent_id: str) -> None:
497
+ await self._archive(agent_id)
498
+
499
+ async def draft(self) -> dict[str, Any] | None:
500
+ """The person's draft: `{spec, edits, presentation}`, or None when they
501
+ have none. `spec` is as it was last saved, its page markup included;
502
+ `edits` is the id of the published agent it is an edit of, or None for a
503
+ new one."""
504
+ return await self._draft()
505
+
506
+ async def save_draft(self, spec: dict[str, Any]) -> dict[str, Any]:
507
+ """Make `spec` the person's draft, replacing the one they had. Validated
508
+ and composed as `preview` does — and refused the same way — and returns
509
+ the same summary. Nothing is published."""
510
+ return await self._save_draft(spec)
511
+
512
+ async def draft_presentation(
513
+ self, value: dict[str, Any] | None = None
514
+ ) -> dict[str, Any] | None:
515
+ """How the draft looks to the person, in the distribution's own terms.
516
+ With `value`, replace it whole (there has to be a draft); either way
517
+ returns what is stored now, None for none."""
518
+ return await self._draft_presentation(value)
519
+
520
+
521
+ class StandingHandle:
522
+ """What this agent has arranged for the person, via `ctx.device.standing`,
523
+ keyed by the agent's own `item_key` — putting the same item again changes
524
+ nothing.
525
+
526
+ An `alert` (a reminder at a time) is kept by the kernel: every phone of the
527
+ person installs it and rings on its own, and the kernel pushes it when no
528
+ phone did. It needs no device and can be put from anywhere.
529
+
530
+ A `location`, `alarm`, `live_activity` or `glance` item is set ON THE DEVICE
531
+ THE PERSON IS USING and confirmed by it before `put` returns. Called from
532
+ anything that is not such a device — a schedule, an event, a browser — it
533
+ raises `ValueError` with `code` `device.no_device` or `device.unsupported`.
534
+
535
+ A mistake the caller can fix raises `ValueError`; a permission the person
536
+ has not given raises one too, whose `code` is `capability_needs_approval`
537
+ (nobody has decided) or `capability_denied`, and whose `capability` is the
538
+ permission to ask them for."""
539
+
540
+ def __init__(
541
+ self,
542
+ put: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]],
543
+ delete: Callable[[str, str | None, str | None], Awaitable[dict[str, Any]]],
544
+ list_: Callable[[], Awaitable[dict[str, Any]]],
545
+ ) -> None:
546
+ self._put = put
547
+ self._delete = delete
548
+ self._list = list_
549
+
550
+ async def put(
551
+ self,
552
+ item_key: str,
553
+ kind: str,
554
+ spec: dict[str, Any],
555
+ *,
556
+ level: str | None = None,
557
+ actions: list[dict[str, Any]] | None = None,
558
+ to: str | None = None,
559
+ share: str | None = None,
560
+ ) -> dict[str, Any]:
561
+ """`kind`: alert, location, alarm, live_activity or glance. Returns
562
+ `{ok, item_key, level, downgraded, changed}`, and for every kind but
563
+ `alert` also `installed` (whether the device confirmed it) with
564
+ `reason` when it did not. `to` (a participant ref) with `share` puts an
565
+ ALERT on that participant's phones instead, on their own permissions."""
566
+ body: dict[str, Any] = {"item_key": item_key, "kind": kind, "spec": spec}
567
+ if to is not None or share is not None:
568
+ body["to"], body["share"] = to, share
569
+ if level is not None:
570
+ body["level"] = level
571
+ if actions is not None:
572
+ body["actions"] = actions
573
+ return await self._put(body)
574
+
575
+ async def delete(
576
+ self, item_key: str, *, to: str | None = None, share: str | None = None
577
+ ) -> dict[str, Any]:
578
+ """Take the item back from every device it is on. Works from anywhere."""
579
+ return await self._delete(item_key, to, share)
580
+
581
+ async def list(self) -> dict[str, Any]:
582
+ """`{items: [{item_key, kind, spec, level, actions, updated_at, here,
583
+ devices: [{device_id, state, reason?, at}]}]}`. `here` says whether the
584
+ item is on the device the person is using now."""
585
+ return await self._list()
586
+
587
+
588
+ class DeviceHandle:
589
+ """The device the person is on, via `ctx.device`. None for a system
590
+ principal.
591
+
592
+ `call(op, args)` runs one operation on that device — read their calendar,
593
+ ask them to pick a photo, get where they are — through the kernel, which
594
+ checks the permission and waits up to 15 seconds. The device is the one
595
+ this request came from: the one the person's message was sent on, or the
596
+ one this agent's page is open on. The kernel reaches no other device of
597
+ theirs, and a request that came from none (a schedule, an event, a chat
598
+ channel, work handed over by another agent) reaches none.
599
+ It returns `{"ok": True, "result": …}` or `{"ok": False, "code", "message"}`
600
+ with `code` one of `device.no_device`, `device.unsupported` (this device
601
+ cannot), `device.unreachable`, `device.os_denied`, `device.declined`,
602
+ `device.not_own`, or `capability_needs_approval` / `capability_denied`. The
603
+ ops and their arguments are listed in the SDK reference."""
604
+
605
+ def __init__(
606
+ self,
607
+ call: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]],
608
+ standing: StandingHandle,
609
+ ) -> None:
610
+ self._call = call
611
+ self.standing = standing
612
+
613
+ async def call(self, op: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
614
+ return await self._call(op, args or {})
615
+
616
+
617
+ class ToolContext:
618
+ """Injected by the kernel; the only surface a tool author should touch."""
619
+
620
+ def __init__(
621
+ self,
622
+ principal: Principal,
623
+ state_factory: Callable[[str], Awaitable[_StateHandle]],
624
+ capability: CapabilityHandle,
625
+ channel: ChannelHandle,
626
+ budget: BudgetHandle,
627
+ now_fn: Callable[[], datetime],
628
+ config: dict[str, Any],
629
+ principal_config: dict[str, Any],
630
+ ask: AskFn | None = None,
631
+ session: SessionHandle | None = None,
632
+ schedule: ScheduleHandle | None = None,
633
+ dispatch: DispatchHandle | None = None,
634
+ attachment: AttachmentFn | None = None,
635
+ embedding: EmbeddingHandle | None = None,
636
+ agent_builder: AgentBuilderHandle | None = None,
637
+ memory: MemoryHandle | None = None,
638
+ todos: TodoHandle | None = None,
639
+ items: ItemHandle | None = None,
640
+ is_admin: AdminFn | None = None,
641
+ workspace: Path | None = None,
642
+ shares: SharesHandle | None = None,
643
+ notify: NotifyFn | None = None,
644
+ device: DeviceHandle | None = None,
645
+ ) -> None:
646
+ self.principal = principal
647
+ self._state_factory = state_factory
648
+ self.capability = capability
649
+ self.channel = channel
650
+ self.budget = budget
651
+ #: Deferred/recurring self-scheduling; None inside an autonomous fire.
652
+ self.schedule = schedule
653
+ #: None when this run may not hand work to a peer. See `DispatchHandle`.
654
+ self.dispatch = dispatch
655
+ self._now = now_fn
656
+ self._config = config
657
+ self._principal_config = principal_config
658
+ self._ask = ask
659
+ #: Ephemeral per-session scratch state (todo/goal); None outside a run.
660
+ self.session = session
661
+ #: Where this run may put files (None outside a run). The built-in file
662
+ #: tools (`system.fs.read`, `system.doc.inspect`) read the same
663
+ #: directory, so a file left here can be handed to them. Give the model
664
+ #: the path RELATIVE to this directory, never the absolute one. Scratch,
665
+ #: not storage: files untouched for a day are deleted, and anything that
666
+ #: must last belongs in the agent's own state.
667
+ self.workspace = workspace
668
+ self._attachment = attachment
669
+ self.embedding = embedding or EmbeddingHandle(None)
670
+ #: Conversational prompt-only agent composition. None for system
671
+ #: principals; tool inventory/capabilities decide who can call it.
672
+ self.agent_builder = agent_builder
673
+ #: What is kept for the person with this agent (memory, to-dos, items).
674
+ #: None for system principals.
675
+ self.memory = memory
676
+ self.todos = todos
677
+ self.items = items
678
+ #: The shares of this agent its person takes part in (`SharesHandle`).
679
+ #: None outside a person's principal (a share's, the system's).
680
+ self.shares = shares
681
+ #: The device the person is on. None for system principals.
682
+ self.device = device
683
+ self._is_admin = is_admin
684
+ self._notify = notify
685
+
686
+ async def notify(
687
+ self,
688
+ text: str,
689
+ *,
690
+ title: str | None = None,
691
+ key: str | None = None,
692
+ level: str | None = None,
693
+ actions: list[dict[str, Any]] | None = None,
694
+ share: str | None = None,
695
+ to: str | None = None,
696
+ category: str | None = None,
697
+ ) -> dict[str, Any]:
698
+ """Tell a person something, now, with no model turn — what a federated
699
+ backend does with `POST /api/agent/notify`.
700
+
701
+ To this principal's person by default; with `to` (a participant `ref`)
702
+ and `share` (a share this principal belongs to), to that participant,
703
+ through their own principal and under their own permission. `level`:
704
+ `passive` | `active` (default) | `time_sensitive` (lowered to `active`
705
+ unless the person allowed `device.notify.urgent`; the receipt then says
706
+ `level_requested`). `actions` `[{id, label, destructive?, foreground?,
707
+ input?}]` come back as `notice.action` events, so they need a
708
+ `provides.events` handler that hears that kind; phones, browsers and
709
+ the web conversation show them, chat apps get the text only. `key` makes a retry
710
+ within a day a no-op; `category` is what a share participant can turn
711
+ off.
712
+
713
+ Returns the receipt `{ok: true, status, level, …}`, or `{ok: false,
714
+ code, message}` when refused (for example `notify.not_allowed`: the
715
+ person has not allowed this agent `person.notify`). Never raises for a
716
+ refusal."""
717
+ if self._notify is None:
718
+ return {
719
+ "ok": False,
720
+ "code": "notify.unavailable",
721
+ "message": "nobody to notify from here",
722
+ }
723
+ body: dict[str, Any] = {"text": text}
724
+ for name, value in (
725
+ ("title", title),
726
+ ("key", key),
727
+ ("level", level),
728
+ ("actions", actions),
729
+ ("share", share),
730
+ ("to", to),
731
+ ("category", category),
732
+ ):
733
+ if value is not None:
734
+ body[name] = value
735
+ return await self._notify(body)
736
+
737
+ async def state(self, namespace_id: str) -> _StateHandle:
738
+ return await self._state_factory(namespace_id)
739
+
740
+ async def attachment(self, attachment_id: str) -> InboundAttachment | None:
741
+ """Fetch one of the caller's own attachments by id (bytes + media_type +
742
+ filename), or None when none of the caller's attachments has that id."""
743
+ if self._attachment is None:
744
+ return None
745
+ return await self._attachment(attachment_id)
746
+
747
+ async def ask(
748
+ self,
749
+ question: str,
750
+ choices: list[str] | None = None,
751
+ fields: list[dict[str, Any]] | None = None,
752
+ ) -> AskReply | None:
753
+ """Ask the user something and wait for the reply over the run's
754
+ interactive channel.
755
+
756
+ `question` + `choices` is the universal form: one sentence and a flat list
757
+ of suggested answers. Every surface can render it — a chat channel, a log
758
+ line, a client written before any of this was structured.
759
+
760
+ `fields` is the SAME question drawn as a form, for a surface that can draw
761
+ one. Each entry is ``{key, label, options, multiple, placeholder}`` (the
762
+ same field shape as a `form` block). It must always be a faithful elaboration of
763
+ `question`, never extra content — a surface that cannot draw a form
764
+ ignores it, and the user still has to be asked the whole thing.
765
+
766
+ Returns an `AskReply`, or None when the run has no back-channel to reach
767
+ the user (e.g. a cron/batch fire) or the wait times out — callers must
768
+ handle None."""
769
+ if self._ask is None:
770
+ return None
771
+ return await self._ask(question, choices, fields)
772
+
773
+ async def is_admin(self) -> bool | None:
774
+ """Whether the person behind this call is one of the deployment's
775
+ administrators.
776
+
777
+ ``True`` a person, an administrator.
778
+ ``False`` a person, not an administrator.
779
+ ``None`` no person behind this call (a scheduled job, a system
780
+ principal), or the deployment cannot say.
781
+
782
+ A fact about a person, not a permission: it authorizes nothing and
783
+ unlocks no tool. Use it to show someone the page that matches their
784
+ role; every write behind that page still needs its ordinary capability.
785
+
786
+ Each call is recorded on the run, so the person can see that an agent
787
+ asked.
788
+ """
789
+ if self._is_admin is None:
790
+ return None
791
+ return await self._is_admin()
792
+
793
+ def now(self) -> datetime:
794
+ return self._now()
795
+
796
+ def config(self, key: str, default: Any = None) -> Any:
797
+ return self._config.get(key, default)
798
+
799
+ def principal_config(self, key: str, default: Any = None) -> Any:
800
+ return self._principal_config.get(key, default)
@@ -0,0 +1,60 @@
1
+ """`@tool` decorator — attaches metadata so the kernel can find the handler.
2
+
3
+ The manifest names the handler by Python ref; this decorator only adds a marker
4
+ attribute. The actual schema is declared in `manifest.yaml`. We deliberately
5
+ don't infer the schema from Python signatures — schema is a manifest concern.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from functools import wraps
12
+ from typing import Any
13
+
14
+ from aipelago.sdk.context import ToolContext
15
+
16
+ ToolHandler = Callable[[ToolContext, dict[str, Any]], Awaitable[dict[str, Any] | Any]]
17
+
18
+
19
+ def tool(
20
+ tool_id: str,
21
+ *,
22
+ side_effects: str = "read-only",
23
+ idempotency: str = "unsafe",
24
+ input_schema: dict[str, Any] | None = None,
25
+ reaches: str | None = None,
26
+ parallelizable: bool = False,
27
+ capability: str | None = None,
28
+ ) -> Callable[[ToolHandler], ToolHandler]:
29
+ """Mark a coroutine as the handler for a tool.
30
+
31
+ In an agent, `manifest.yaml` is the source of truth for everything about a
32
+ tool, so only `tool_id` matters here and the other arguments are ignored.
33
+ They describe the kernel's own built-in tools, which have no manifest:
34
+
35
+ * `side_effects` — `read-only` or not; it decides the sandbox a call runs in.
36
+ * `input_schema` — JSON Schema for the arguments, shown to the model and
37
+ checked before the call.
38
+ * `reaches` — the argument holding the host the tool talks to, when it is
39
+ not `url`, so the person can be asked about that host.
40
+ * `parallelizable` — two calls in one turn may run together (read-only
41
+ tools only).
42
+ * `capability` — the permission every call needs, decided (asking the
43
+ person when it is theirs to give) before the handler runs.
44
+ """
45
+
46
+ def deco(fn: ToolHandler) -> ToolHandler:
47
+ @wraps(fn)
48
+ async def wrapped(ctx: ToolContext, args: dict[str, Any]) -> Any:
49
+ return await fn(ctx, args)
50
+
51
+ wrapped.__aipelago_tool_id__ = tool_id # type: ignore[attr-defined]
52
+ wrapped.__aipelago_side_effects__ = side_effects # type: ignore[attr-defined]
53
+ wrapped.__aipelago_idempotency__ = idempotency # type: ignore[attr-defined]
54
+ wrapped.__aipelago_input_schema__ = input_schema # type: ignore[attr-defined]
55
+ wrapped.__aipelago_reaches__ = reaches # type: ignore[attr-defined]
56
+ wrapped.__aipelago_parallelizable__ = parallelizable # type: ignore[attr-defined]
57
+ wrapped.__aipelago_capability__ = capability # type: ignore[attr-defined]
58
+ return wrapped
59
+
60
+ return deco
@@ -0,0 +1,81 @@
1
+ """Lifecycle hooks: the `@hook` decorator and the `HookDecision` a handler returns.
2
+
3
+ `@hook` mirrors `@tool`: the manifest declares the hook (event + handler ref +
4
+ matcher); this decorator only marks the handler. A handler receives the event
5
+ payload dict and returns a `HookDecision` (or None = allow).
6
+
7
+ from aipelago.sdk import hook, HookDecision
8
+
9
+ @hook("pre_tool_use")
10
+ async def block_secrets(payload: dict) -> HookDecision | None:
11
+ if "secret" in str(payload.get("args", {})):
12
+ return HookDecision(decision="deny", reason="no secrets in args")
13
+ return None
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Awaitable, Callable
19
+ from dataclasses import dataclass
20
+ from functools import wraps
21
+ from typing import Any
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class HookDecision:
26
+ """A hook's verdict. A handler returns this (or None = allow); an external
27
+ command prints it as JSON on stdout. Which fields count depends on the event:
28
+ ``args`` on pre_tool_use, ``text`` on user_prompt_submit, ``output`` on
29
+ post_tool_use; ``steer`` is guidance the model sees, on deny too, on the
30
+ events that accept it. Events that only observe ignore every field."""
31
+
32
+ decision: str = "allow" # "allow" | "deny"
33
+ reason: str | None = None
34
+ args: dict[str, Any] | None = None
35
+ text: str | None = None
36
+ output: Any | None = None
37
+ steer: str | None = None
38
+
39
+ @property
40
+ def is_deny(self) -> bool:
41
+ return self.decision == "deny"
42
+
43
+ @classmethod
44
+ def allow(cls) -> HookDecision:
45
+ return cls()
46
+
47
+ @classmethod
48
+ def from_payload(cls, raw: Any) -> HookDecision:
49
+ """Parse a hook's raw output (e.g. JSON from an external command) into a
50
+ decision. Anything that is not a dict reads as allow."""
51
+ if not isinstance(raw, dict):
52
+ return cls.allow()
53
+ decision = "deny" if str(raw.get("decision", "allow")).lower() == "deny" else "allow"
54
+ args = raw.get("args")
55
+ text = raw.get("text")
56
+ steer = raw.get("steer")
57
+ return cls(
58
+ decision=decision,
59
+ reason=raw.get("reason"),
60
+ args=args if isinstance(args, dict) else None,
61
+ text=str(text) if text is not None else None,
62
+ output=raw.get("output"),
63
+ steer=str(steer) if steer is not None else None,
64
+ )
65
+
66
+
67
+ HookHandler = Callable[[dict[str, Any]], Awaitable["HookDecision | None"]]
68
+
69
+
70
+ def hook(event: str) -> Callable[[HookHandler], HookHandler]:
71
+ """Mark a coroutine as the handler for a manifest-declared lifecycle hook."""
72
+
73
+ def deco(fn: HookHandler) -> HookHandler:
74
+ @wraps(fn)
75
+ async def wrapped(payload: dict[str, Any]) -> Any:
76
+ return await fn(payload)
77
+
78
+ wrapped.__aipelago_hook_event__ = event # type: ignore[attr-defined]
79
+ return wrapped
80
+
81
+ return deco
@@ -0,0 +1,66 @@
1
+ """`Principal`: whom a call is made for — `(owner, agent)`, the owner a person or
2
+ a share. A tool handler reads it as `ctx.principal`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ from enum import StrEnum
9
+ from fnmatch import fnmatchcase
10
+ from uuid import UUID
11
+
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+
15
+ class PrincipalStatus(StrEnum):
16
+ active = "active"
17
+ suspended = "suspended"
18
+ removed = "removed"
19
+
20
+
21
+ class Principal(BaseModel):
22
+ """An `(owner, agent)` row. The unit of state/capability/budget partitioning.
23
+
24
+ Exactly one of three shapes: a person's (`user_id`), a share's (`share_id` —
25
+ a thing or a conversation several people use together, which owns its own
26
+ rows), or the deployment's own identity for the agent (neither)."""
27
+
28
+ model_config = ConfigDict(frozen=True)
29
+
30
+ principal_id: UUID
31
+ user_id: UUID | None
32
+ agent_id: str
33
+ activated_at: datetime
34
+ status: PrincipalStatus
35
+ #: When the call came with a token limited to some of the agent's
36
+ #: capabilities, the patterns it is limited to; None = no such limit. It
37
+ #: only ever narrows what the agent was granted.
38
+ capability_scope: frozenset[str] | None = None
39
+ #: When the person arrived from your own product, signed in there, their id
40
+ #: in your product; tokens minted for your backend carry it as `sub`. None
41
+ #: otherwise.
42
+ native_sub: str | None = None
43
+ #: The share this principal is the owner-of-rows for; None for a person's
44
+ #: or the system's.
45
+ share_id: UUID | None = None
46
+ #: The person's device this request came from — the one their message was
47
+ #: sent on, or the one the agent's page is open on. The only device
48
+ #: `ctx.device` reaches for this request; None for anything that did not
49
+ #: come from one (a schedule, an event, a chat channel, handed-over work).
50
+ device_id: UUID | None = None
51
+
52
+ @property
53
+ def is_system(self) -> bool:
54
+ """System principals have no owner — driven only by cron/event triggers."""
55
+ return self.user_id is None and self.share_id is None
56
+
57
+ @property
58
+ def is_share(self) -> bool:
59
+ """A share's principal: its rows are the shared thing's, and whoever
60
+ acts on it is a participant, not its owner."""
61
+ return self.share_id is not None
62
+
63
+ def scope_permits(self, capability_id: str) -> bool:
64
+ if self.capability_scope is None:
65
+ return True
66
+ return any(fnmatchcase(capability_id, g) for g in self.capability_scope)
@@ -0,0 +1,22 @@
1
+ # Ships `aipelago/sdk/` and NOT `aipelago/__init__.py`, so `aipelago` is a
2
+ # namespace package here and never shadows the kernel's own package.
3
+ [project]
4
+ name = "aipelago-sdk"
5
+ version = "0.6.1"
6
+ description = "The SDK agents are written against: @tool, @hook and the ToolContext a handler receives."
7
+ readme = "README.md"
8
+ requires-python = ">=3.11"
9
+ license = "Apache-2.0"
10
+ license-files = ["LICENSE"]
11
+ authors = [{ name = "Jiaan Liu", email = "jiaanliux@gmail.com" }]
12
+ dependencies = ["pydantic==2.12.0"]
13
+
14
+ [build-system]
15
+ requires = ["hatchling==1.27.0"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.sdist]
19
+ only-include = ["aipelago", "README.md", "LICENSE"]
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["aipelago"]