elan-sdk 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.
- elan_sdk/__init__.py +175 -0
- elan_sdk/_tls.py +35 -0
- elan_sdk/_version.py +8 -0
- elan_sdk/actions.py +253 -0
- elan_sdk/agents.py +44 -0
- elan_sdk/analytics.py +323 -0
- elan_sdk/approvals.py +182 -0
- elan_sdk/artifacts.py +213 -0
- elan_sdk/auth.py +182 -0
- elan_sdk/autonomy.py +166 -0
- elan_sdk/client.py +210 -0
- elan_sdk/config.py +83 -0
- elan_sdk/errors.py +297 -0
- elan_sdk/http.py +269 -0
- elan_sdk/models/__init__.py +14 -0
- elan_sdk/models/actions.py +436 -0
- elan_sdk/models/agents.py +100 -0
- elan_sdk/models/events.py +183 -0
- elan_sdk/models/invocations.py +194 -0
- elan_sdk/models/money.py +601 -0
- elan_sdk/money.py +58 -0
- elan_sdk/py.typed +0 -0
- elan_sdk/runs.py +185 -0
- elan_sdk/runtime.py +277 -0
- elan_sdk/sessions.py +192 -0
- elan_sdk/stream.py +294 -0
- elan_sdk/sync.py +238 -0
- elan_sdk/tickets.py +71 -0
- elan_sdk/tools.py +84 -0
- elan_sdk/wallets.py +197 -0
- elan_sdk-0.1.0.dist-info/METADATA +213 -0
- elan_sdk-0.1.0.dist-info/RECORD +34 -0
- elan_sdk-0.1.0.dist-info/WHEEL +4 -0
- elan_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
elan_sdk/__init__.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The Elan API, as a developer's backend calls it.
|
|
2
|
+
|
|
3
|
+
from elan_sdk import AsyncElan
|
|
4
|
+
|
|
5
|
+
async with AsyncElan.from_env() as elan:
|
|
6
|
+
print((await elan.whoami())["tenant_id"])
|
|
7
|
+
|
|
8
|
+
ONE BASE URL AND ONE CREDENTIAL. Two services sit behind that host, split by
|
|
9
|
+
path, and the ledger and the action store are not publicly routed at all —
|
|
10
|
+
which is the client's problem and not yours.
|
|
11
|
+
|
|
12
|
+
WHAT THIS DOES NOT COVER, named here because it is the first question: projects,
|
|
13
|
+
deployments, secrets, validation and service users belong to the `elan` CLI and
|
|
14
|
+
are documented in `docs/developer-docs/api.md`. This is the runtime, the money
|
|
15
|
+
and the approvals — the calls a product makes while it is serving a request.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from elan_sdk._version import __version__
|
|
19
|
+
from elan_sdk.artifacts import UploadTicket
|
|
20
|
+
from elan_sdk.auth import ClientCredentials, StaticToken, TokenSource, claims_of
|
|
21
|
+
from elan_sdk.client import AsyncElan
|
|
22
|
+
from elan_sdk.config import Config
|
|
23
|
+
from elan_sdk.errors import (
|
|
24
|
+
BadRequest,
|
|
25
|
+
Conflict,
|
|
26
|
+
CredentialsRefused,
|
|
27
|
+
DependencyRefused,
|
|
28
|
+
ElanError,
|
|
29
|
+
EndUserEventsElsewhere,
|
|
30
|
+
Forbidden,
|
|
31
|
+
NotFound,
|
|
32
|
+
NotWired,
|
|
33
|
+
PaymentRequired,
|
|
34
|
+
Problem,
|
|
35
|
+
ProjectNotNamed,
|
|
36
|
+
ServerError,
|
|
37
|
+
StreamClosed,
|
|
38
|
+
StreamError,
|
|
39
|
+
StreamRefused,
|
|
40
|
+
TicketIsNotACredential,
|
|
41
|
+
TicketRefused,
|
|
42
|
+
TransportError,
|
|
43
|
+
Unauthenticated,
|
|
44
|
+
Unavailable,
|
|
45
|
+
)
|
|
46
|
+
from elan_sdk.models.actions import (
|
|
47
|
+
Action,
|
|
48
|
+
ActionBatch,
|
|
49
|
+
ActionPage,
|
|
50
|
+
BatchOutcome,
|
|
51
|
+
Decision,
|
|
52
|
+
Label,
|
|
53
|
+
Policy,
|
|
54
|
+
PolicyScopes,
|
|
55
|
+
StoredPolicy,
|
|
56
|
+
Ticket,
|
|
57
|
+
ToolCall,
|
|
58
|
+
ToolOutcome,
|
|
59
|
+
)
|
|
60
|
+
from elan_sdk.models.events import (
|
|
61
|
+
CONTENT_KINDS,
|
|
62
|
+
KNOWN_KINDS,
|
|
63
|
+
PROGRESS_KINDS,
|
|
64
|
+
TERMINAL_KINDS,
|
|
65
|
+
AgentEvent,
|
|
66
|
+
author_of,
|
|
67
|
+
dedupe_by_path,
|
|
68
|
+
text_of,
|
|
69
|
+
)
|
|
70
|
+
from elan_sdk.models.money import (
|
|
71
|
+
Balance,
|
|
72
|
+
ChargedRun,
|
|
73
|
+
CostRow,
|
|
74
|
+
HoldPage,
|
|
75
|
+
HoldRow,
|
|
76
|
+
InvocationCharge,
|
|
77
|
+
LatencyBucket,
|
|
78
|
+
LatencySeries,
|
|
79
|
+
ModelUsage,
|
|
80
|
+
MoneyBucket,
|
|
81
|
+
MoneySeries,
|
|
82
|
+
RunBucket,
|
|
83
|
+
RunSeries,
|
|
84
|
+
StatementEntry,
|
|
85
|
+
StatementPage,
|
|
86
|
+
TokenBucket,
|
|
87
|
+
TokenSeries,
|
|
88
|
+
ToolBucket,
|
|
89
|
+
ToolSeries,
|
|
90
|
+
Transaction,
|
|
91
|
+
UsageRow,
|
|
92
|
+
Wallet,
|
|
93
|
+
WalletPage,
|
|
94
|
+
)
|
|
95
|
+
from elan_sdk.money import MICROS_PER_USD, dollars, micros
|
|
96
|
+
from elan_sdk.sync import Elan
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"Action",
|
|
100
|
+
"ActionBatch",
|
|
101
|
+
"ActionPage",
|
|
102
|
+
"AgentEvent",
|
|
103
|
+
"AsyncElan",
|
|
104
|
+
"BadRequest",
|
|
105
|
+
"Balance",
|
|
106
|
+
"BatchOutcome",
|
|
107
|
+
"CONTENT_KINDS",
|
|
108
|
+
"ChargedRun",
|
|
109
|
+
"ClientCredentials",
|
|
110
|
+
"Config",
|
|
111
|
+
"Conflict",
|
|
112
|
+
"CostRow",
|
|
113
|
+
"CredentialsRefused",
|
|
114
|
+
"Decision",
|
|
115
|
+
"DependencyRefused",
|
|
116
|
+
"Elan",
|
|
117
|
+
"ElanError",
|
|
118
|
+
"EndUserEventsElsewhere",
|
|
119
|
+
"Forbidden",
|
|
120
|
+
"HoldPage",
|
|
121
|
+
"HoldRow",
|
|
122
|
+
"InvocationCharge",
|
|
123
|
+
"KNOWN_KINDS",
|
|
124
|
+
"Label",
|
|
125
|
+
"LatencyBucket",
|
|
126
|
+
"LatencySeries",
|
|
127
|
+
"MICROS_PER_USD",
|
|
128
|
+
"ModelUsage",
|
|
129
|
+
"MoneyBucket",
|
|
130
|
+
"MoneySeries",
|
|
131
|
+
"NotFound",
|
|
132
|
+
"NotWired",
|
|
133
|
+
"PROGRESS_KINDS",
|
|
134
|
+
"PaymentRequired",
|
|
135
|
+
"Policy",
|
|
136
|
+
"PolicyScopes",
|
|
137
|
+
"Problem",
|
|
138
|
+
"ProjectNotNamed",
|
|
139
|
+
"RunBucket",
|
|
140
|
+
"RunSeries",
|
|
141
|
+
"ServerError",
|
|
142
|
+
"StatementEntry",
|
|
143
|
+
"StatementPage",
|
|
144
|
+
"StaticToken",
|
|
145
|
+
"StoredPolicy",
|
|
146
|
+
"StreamClosed",
|
|
147
|
+
"StreamError",
|
|
148
|
+
"StreamRefused",
|
|
149
|
+
"TERMINAL_KINDS",
|
|
150
|
+
"Ticket",
|
|
151
|
+
"UploadTicket",
|
|
152
|
+
"TicketIsNotACredential",
|
|
153
|
+
"TicketRefused",
|
|
154
|
+
"TokenBucket",
|
|
155
|
+
"TokenSeries",
|
|
156
|
+
"TokenSource",
|
|
157
|
+
"ToolBucket",
|
|
158
|
+
"ToolCall",
|
|
159
|
+
"ToolOutcome",
|
|
160
|
+
"ToolSeries",
|
|
161
|
+
"Transaction",
|
|
162
|
+
"TransportError",
|
|
163
|
+
"Unauthenticated",
|
|
164
|
+
"Unavailable",
|
|
165
|
+
"UsageRow",
|
|
166
|
+
"Wallet",
|
|
167
|
+
"WalletPage",
|
|
168
|
+
"__version__",
|
|
169
|
+
"author_of",
|
|
170
|
+
"claims_of",
|
|
171
|
+
"dedupe_by_path",
|
|
172
|
+
"dollars",
|
|
173
|
+
"micros",
|
|
174
|
+
"text_of",
|
|
175
|
+
]
|
elan_sdk/_tls.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A TLS context that trusts the same roots every HTTP call here does.
|
|
2
|
+
|
|
3
|
+
MOVED FROM `elan_cli/stream.py`, WHICH FOUND THIS THE HARD WAY. macOS's
|
|
4
|
+
python.org builds ship NO CA BUNDLE. OpenSSL's default path finds nothing, so a
|
|
5
|
+
perfectly valid Let's Encrypt certificate fails with
|
|
6
|
+
|
|
7
|
+
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate
|
|
8
|
+
|
|
9
|
+
while `curl` and `openssl s_client` accept it on the same machine — and every
|
|
10
|
+
HTTP call in this SDK works, because httpx bundles certifi and `websockets`
|
|
11
|
+
uses the system default. So the symptom is ONE FEATURE failing on TLS while
|
|
12
|
+
everything else is fine, which reads as a server problem.
|
|
13
|
+
|
|
14
|
+
`scripts/zitadel_configure.py` carries the same note for the same reason. This
|
|
15
|
+
is that lesson arriving in a third place, which is why it is a module rather
|
|
16
|
+
than a line.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def trust() -> object | None:
|
|
23
|
+
"""A certifi-backed SSL context, or None to fall back to the system's."""
|
|
24
|
+
import ssl # noqa: PLC0415
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import certifi # noqa: PLC0415
|
|
28
|
+
except ImportError: # pragma: no cover - certifi is a declared dependency
|
|
29
|
+
# The system default is still worth trying: on Linux it is correct, and
|
|
30
|
+
# failing outright here would be worse than failing at the handshake.
|
|
31
|
+
return None
|
|
32
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["trust"]
|
elan_sdk/_version.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""One version number, two registries.
|
|
2
|
+
|
|
3
|
+
The npm package publishes the same string, and
|
|
4
|
+
`tests/test_sdk_packaging.py` asserts they agree — because a developer reading
|
|
5
|
+
two SDKs' versions should be able to tell whether they describe the same API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
elan_sdk/actions.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Tool approvals — and the batch, which is the thing to get right.
|
|
2
|
+
|
|
3
|
+
THE NEXT MESSAGE RESOLVES EVERY CONFIRMATION A TURN RAISED. ADK keeps no notion
|
|
4
|
+
of answering one of three, so deciding one and leaving the rest silently
|
|
5
|
+
abandons them while the run reports finished — and sending a plain message
|
|
6
|
+
abandons all of them with nothing said. So a turn's answers travel as ONE batch,
|
|
7
|
+
published only when its last member is decided.
|
|
8
|
+
|
|
9
|
+
`decide_batch` IS THEREFORE THE METHOD TO REACH FOR, and `decide` is honest
|
|
10
|
+
about what it returns: `Decision.published` is false until the batch completes,
|
|
11
|
+
which is the only way to know whether anything actually ran.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from elan_sdk.models.actions import (
|
|
20
|
+
Action,
|
|
21
|
+
ActionBatch,
|
|
22
|
+
ActionPage,
|
|
23
|
+
BatchOutcome,
|
|
24
|
+
Decision,
|
|
25
|
+
Label,
|
|
26
|
+
action_from,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Actions:
|
|
31
|
+
"""What is waiting on a person, and answering it."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, http, config=None) -> None:
|
|
34
|
+
self._http = http
|
|
35
|
+
self._config = config
|
|
36
|
+
|
|
37
|
+
def _project(self, project: str | None) -> str:
|
|
38
|
+
"""WHICH OF THE TENANT'S PRODUCTS. Narrows; the credential authorises.
|
|
39
|
+
|
|
40
|
+
An agent slug is unique within a PROJECT and not within a tenant
|
|
41
|
+
(ADR-0054), so without this one inbox holds every project's
|
|
42
|
+
confirmations for the same target. Defaults to the client's own
|
|
43
|
+
project; pass `""` to see every project the credential may see. A
|
|
44
|
+
credential issued for one project is narrowed to it server-side
|
|
45
|
+
whether it asked or not, and naming a sibling is a `Forbidden`.
|
|
46
|
+
"""
|
|
47
|
+
if project is not None:
|
|
48
|
+
return project
|
|
49
|
+
return getattr(self._config, "project", "") or ""
|
|
50
|
+
|
|
51
|
+
async def list(self, *, project: str | None = None, status: str = "pending",
|
|
52
|
+
labels: Sequence[str] = (), target: str = "",
|
|
53
|
+
session_id: str = "",
|
|
54
|
+
scope: Literal["mine", "tenant"] = "mine",
|
|
55
|
+
automatic: bool | None = None,
|
|
56
|
+
kind: Literal["confirmation", "tool", "approval",
|
|
57
|
+
"all"] = "confirmation",
|
|
58
|
+
user: str = "",
|
|
59
|
+
limit: int = 100, offset: int = 0) -> ActionPage:
|
|
60
|
+
"""Pending work, filtered.
|
|
61
|
+
|
|
62
|
+
`kind` DEFAULTS TO CONFIRMATIONS, which is a choice about what an inbox
|
|
63
|
+
is for: this store also holds long-running tool calls, whose answer is
|
|
64
|
+
a system's result rather than a person's judgement, and a queue mixing
|
|
65
|
+
the two is a queue nobody can act on. Those are `elan.tools`.
|
|
66
|
+
|
|
67
|
+
THERE ARE THREE KINDS, and `approval` was missing from this annotation
|
|
68
|
+
while the store accepted it throughout — so the one kind a developer
|
|
69
|
+
could not list through a typed client was the one with no namespace of
|
|
70
|
+
its own either. Those are `elan.approvals` now. `all` is every kind,
|
|
71
|
+
which the store accepts and anything else it refuses.
|
|
72
|
+
|
|
73
|
+
`automatic` IS TRI-STATE. None is every row, False is "only what a
|
|
74
|
+
person decided", True is "only what policy decided" — so a bool
|
|
75
|
+
default would delete one of the three.
|
|
76
|
+
|
|
77
|
+
LABELS COMBINE AS ANY-OF, because a category filter that narrowed to
|
|
78
|
+
the intersection usually shows nothing at all.
|
|
79
|
+
|
|
80
|
+
`user` NAMES ONE OF YOUR OWN END USERS as `eu:<id>`. It contradicts
|
|
81
|
+
`scope="tenant"`, which means every user, so sending both is a
|
|
82
|
+
`BadRequest` rather than a silent precedence.
|
|
83
|
+
|
|
84
|
+
`limit` REALLY IS THE LIMIT NOW. It was sent and dropped in transit, so
|
|
85
|
+
every caller received at most 100 rows beside a `total` reporting the
|
|
86
|
+
truth; `offset` walks the rest and `ActionPage.more` says whether to.
|
|
87
|
+
"""
|
|
88
|
+
params: dict[str, Any] = {"project": self._project(project),
|
|
89
|
+
"status": status, "scope": scope,
|
|
90
|
+
"kind": kind, "limit": limit,
|
|
91
|
+
"offset": offset, "user": user,
|
|
92
|
+
"target": target, "session_id": session_id}
|
|
93
|
+
if automatic is not None:
|
|
94
|
+
params["automatic"] = automatic
|
|
95
|
+
if labels:
|
|
96
|
+
# A REPEATED PARAMETER AND NOT A JOINED ONE. The route declares
|
|
97
|
+
# `label: list[str] = Query(default=[])`, so `?label=a&label=b` is
|
|
98
|
+
# what it reads — a comma-joined value would arrive as one label
|
|
99
|
+
# named "a,b" and match nothing, silently.
|
|
100
|
+
params["label"] = list(labels)
|
|
101
|
+
answer = await self._http.get("/v1/actions", params=params)
|
|
102
|
+
rows = answer.get("actions", [])
|
|
103
|
+
return ActionPage(actions=tuple(action_from(row) for row in rows),
|
|
104
|
+
total=int(answer.get("total", len(rows))),
|
|
105
|
+
limit=int(answer.get("limit", limit)),
|
|
106
|
+
offset=int(answer.get("offset", offset)))
|
|
107
|
+
|
|
108
|
+
async def labels(self, *, scope: Literal["mine", "tenant"] = "mine",
|
|
109
|
+
project: str | None = None,
|
|
110
|
+
user: str = "") -> tuple[Label, ...]:
|
|
111
|
+
"""Which categories have work waiting, and how much.
|
|
112
|
+
|
|
113
|
+
THE ROUTE ANSWERS A BARE LIST where `/v1/actions` answers an object.
|
|
114
|
+
Normalised here so a caller never has to know which.
|
|
115
|
+
"""
|
|
116
|
+
rows = await self._http.get(
|
|
117
|
+
"/v1/actions/labels",
|
|
118
|
+
params={"scope": scope, "project": self._project(project),
|
|
119
|
+
"user": user})
|
|
120
|
+
return tuple(Label(label=row.get("label", ""),
|
|
121
|
+
pending=int(row.get("pending", 0)))
|
|
122
|
+
for row in rows or ())
|
|
123
|
+
|
|
124
|
+
async def get(self, action_id: str) -> Action | None:
|
|
125
|
+
"""One action, or None.
|
|
126
|
+
|
|
127
|
+
VIA THE LIST, BECAUSE NO SINGLE-ACTION ROUTE IS PUBLISHED. rt-service
|
|
128
|
+
proxies the list, the labels and the decision, and nothing else — so
|
|
129
|
+
this is a filter rather than a fetch, and says so.
|
|
130
|
+
"""
|
|
131
|
+
page = await self.list(status="all", kind="all", limit=500)
|
|
132
|
+
for action in page.actions:
|
|
133
|
+
if action.action_id == action_id:
|
|
134
|
+
return action
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
async def batches(self, *, project: str | None = None,
|
|
138
|
+
session_id: str = "", target: str = "",
|
|
139
|
+
status: str = "pending",
|
|
140
|
+
kind: Literal["confirmation", "tool", "all"] = "all",
|
|
141
|
+
scope: Literal["mine", "tenant"] = "mine",
|
|
142
|
+
user: str = "") -> tuple[ActionBatch, ...]:
|
|
143
|
+
"""Pending work GROUPED BY TURN.
|
|
144
|
+
|
|
145
|
+
THE API HAS NO BATCH FILTER AND THE GROUPING IS WHAT A CALLER MUST NOT
|
|
146
|
+
GET WRONG, so it is done here rather than left as an exercise: every
|
|
147
|
+
member of a turn has to be answered before any of them takes effect.
|
|
148
|
+
|
|
149
|
+
`kind` DEFAULTS TO EVERYTHING HERE, unlike `list()`, because a batch is
|
|
150
|
+
a unit of work rather than an inbox — and a mixed turn's confirmation
|
|
151
|
+
half is meaningless without knowing a tool call is also outstanding.
|
|
152
|
+
|
|
153
|
+
`scope` AND `user` WERE MISSING HERE while `list()` and `labels()` both
|
|
154
|
+
took them, which made an admin's tenant-wide view ungroupable and made
|
|
155
|
+
this method unusable for the shape the SDK is built around — a backend
|
|
156
|
+
answering for its end users, whose confirmations are recorded against
|
|
157
|
+
`eu:<id>` and which `scope="mine"` therefore never finds. TypeScript
|
|
158
|
+
gained `scope` and this did not; both have all three now.
|
|
159
|
+
"""
|
|
160
|
+
per_page = 500
|
|
161
|
+
found: list[Action] = []
|
|
162
|
+
offset = 0
|
|
163
|
+
while True:
|
|
164
|
+
# EVERY PAGE, NOT THE FIRST. Grouping is the whole point here, and
|
|
165
|
+
# a turn straddling a page boundary would be grouped from whichever
|
|
166
|
+
# members landed on it — then handed to `decide_batch`, which would
|
|
167
|
+
# answer those and silently abandon the rest. That is the exact
|
|
168
|
+
# failure this type exists to prevent, so it cannot depend on
|
|
169
|
+
# whether a caller remembered to page.
|
|
170
|
+
page = await self.list(project=project, status=status, target=target,
|
|
171
|
+
session_id=session_id, kind=kind, scope=scope,
|
|
172
|
+
user=user,
|
|
173
|
+
limit=per_page, offset=offset)
|
|
174
|
+
found.extend(page.actions)
|
|
175
|
+
if not page.actions or not page.more:
|
|
176
|
+
break
|
|
177
|
+
offset += per_page
|
|
178
|
+
grouped: dict[str, list[Action]] = {}
|
|
179
|
+
for action in found:
|
|
180
|
+
grouped.setdefault(action.batch_id, []).append(action)
|
|
181
|
+
return tuple(
|
|
182
|
+
ActionBatch(batch_id=batch_id,
|
|
183
|
+
invocation_id=members[0].invocation_id,
|
|
184
|
+
session_id=members[0].session_id,
|
|
185
|
+
target_slug=members[0].target_slug,
|
|
186
|
+
actions=tuple(members))
|
|
187
|
+
for batch_id, members in grouped.items())
|
|
188
|
+
|
|
189
|
+
async def decide(self, action_id: str, *, approved: bool, note: str = "",
|
|
190
|
+
payload: Any = None,
|
|
191
|
+
approve_all: bool = False,
|
|
192
|
+
user: str = "") -> Decision:
|
|
193
|
+
"""Answer ONE confirmation.
|
|
194
|
+
|
|
195
|
+
PREFER `decide_batch` UNLESS YOU KNOW THE TURN RAISED ONE. Answering
|
|
196
|
+
one of two silently abandons the other, and this method cannot tell you
|
|
197
|
+
that happened — only `published` tells you whether the batch completed.
|
|
198
|
+
|
|
199
|
+
`approve_all` WRITES A STANDING GRANT so the agent stops asking, and it
|
|
200
|
+
is recorded against the RUN'S OWNER rather than the decider: an admin
|
|
201
|
+
approving somebody else's card must not quietly grant themselves
|
|
202
|
+
autonomy while leaving the colleague asked about every call.
|
|
203
|
+
|
|
204
|
+
`user` NAMES THE END USER THIS CARD BELONGS TO, for a backend answering
|
|
205
|
+
on their behalf. It authorises the call and does not change
|
|
206
|
+
`decided_by`, which is always the identity that really decided.
|
|
207
|
+
"""
|
|
208
|
+
body: dict[str, Any] = {"approved": approved, "note": note,
|
|
209
|
+
"payload": payload, "approve_all": approve_all}
|
|
210
|
+
if user:
|
|
211
|
+
body["user"] = user
|
|
212
|
+
answer = await self._http.post(
|
|
213
|
+
f"/v1/actions/{action_id}/decide", json=body)
|
|
214
|
+
return _decision(answer)
|
|
215
|
+
|
|
216
|
+
async def decide_batch(self, batch: ActionBatch | Sequence[str], *,
|
|
217
|
+
approved: bool, note: str = "", payload: Any = None,
|
|
218
|
+
approve_all: bool = False,
|
|
219
|
+
user: str = "") -> BatchOutcome:
|
|
220
|
+
"""Answer a whole turn, in order, and report what happened.
|
|
221
|
+
|
|
222
|
+
THE LAST ANSWER IS THE ONE THAT PUBLISHES, so these go one at a time
|
|
223
|
+
and the outcome carries every decision — including any the engine
|
|
224
|
+
recorded as abandoned, which can follow an approval and means NOTHING
|
|
225
|
+
RAN.
|
|
226
|
+
"""
|
|
227
|
+
ids = (list(batch.action_ids) if isinstance(batch, ActionBatch)
|
|
228
|
+
else list(batch))
|
|
229
|
+
decisions: list[Decision] = []
|
|
230
|
+
for action_id in ids:
|
|
231
|
+
decisions.append(await self.decide(
|
|
232
|
+
action_id, approved=approved, note=note, payload=payload,
|
|
233
|
+
approve_all=approve_all, user=user))
|
|
234
|
+
last = decisions[-1] if decisions else None
|
|
235
|
+
return BatchOutcome(
|
|
236
|
+
batch_id=last.batch_id if last else "",
|
|
237
|
+
decisions=tuple(decisions),
|
|
238
|
+
complete=bool(last and last.batch_complete),
|
|
239
|
+
published=any(d.published for d in decisions))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _decision(answer: dict[str, Any]) -> Decision:
|
|
243
|
+
return Decision(
|
|
244
|
+
action_id=answer.get("action_id", ""),
|
|
245
|
+
status=answer.get("status", ""),
|
|
246
|
+
batch_id=answer.get("batch_id", ""),
|
|
247
|
+
batch_complete=bool(answer.get("batch_complete")),
|
|
248
|
+
published=bool(answer.get("published")),
|
|
249
|
+
rule_id=answer.get("rule_id"),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = ["Actions"]
|
elan_sdk/agents.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What this tenant has deployed.
|
|
2
|
+
|
|
3
|
+
A READ, AND ONLY A READ. Deploying is the CLI's and the console's business
|
|
4
|
+
(ADR-0104 scoped projects and deployments out of this SDK); what a backend
|
|
5
|
+
needs is what is running right now and what it declares — to render a card, or
|
|
6
|
+
to explain to one of your users why a permission they granted did not take
|
|
7
|
+
effect.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from elan_sdk.models.agents import DeployedAgent, agent_from
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Agents:
|
|
16
|
+
"""Reading what is deployed, and what each target declares."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, http, config) -> None:
|
|
19
|
+
self._http = http
|
|
20
|
+
self._config = config
|
|
21
|
+
|
|
22
|
+
async def list(self, *, project: str | None = None) -> list[DeployedAgent]:
|
|
23
|
+
"""Every target your credential may see.
|
|
24
|
+
|
|
25
|
+
`project` DEFAULTS TO THE CLIENT'S OWN, and `""` means every project
|
|
26
|
+
the credential may see — the same convention as `actions.list()`.
|
|
27
|
+
"""
|
|
28
|
+
wanted = self._config.project if project is None else project
|
|
29
|
+
answer = await self._http.get("/v1/agents", params={"project": wanted})
|
|
30
|
+
return [agent_from(row) for row in (answer or {}).get("agents") or ()]
|
|
31
|
+
|
|
32
|
+
async def get(self, slug: str, *,
|
|
33
|
+
project: str | None = None) -> DeployedAgent | None:
|
|
34
|
+
"""One target, or None.
|
|
35
|
+
|
|
36
|
+
NONE RATHER THAN AN EXCEPTION, because "is this deployed" is a question
|
|
37
|
+
with a negative answer. There is no single-agent route, so this filters
|
|
38
|
+
the listing — which is also why it cannot tell "not deployed" from "not
|
|
39
|
+
yours", and must not, since the listing already collapses the two.
|
|
40
|
+
"""
|
|
41
|
+
for agent in await self.list(project=project):
|
|
42
|
+
if agent.slug == slug:
|
|
43
|
+
return agent
|
|
44
|
+
return None
|