getstack 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.
@@ -0,0 +1,57 @@
1
+ """Notification channel management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+ from .types import NotificationChannel
9
+
10
+
11
+ def _to_channel(data: dict[str, Any]) -> NotificationChannel:
12
+ return NotificationChannel(
13
+ id=data["id"],
14
+ channel_type=data["channel_type"],
15
+ destination=data["destination"],
16
+ events=data.get("events", []),
17
+ min_severity=data.get("min_severity", "warning"),
18
+ active=data.get("active", True),
19
+ verified=data.get("verified", False),
20
+ )
21
+
22
+
23
+ class NotificationService:
24
+ def __init__(self, client: HttpClient):
25
+ self._client = client
26
+
27
+ def list(self) -> list[NotificationChannel]:
28
+ data = self._client.get("/v1/notifications/channels")
29
+ return [_to_channel(ch) for ch in data]
30
+
31
+ def create(
32
+ self,
33
+ channel_type: str,
34
+ destination: str,
35
+ events: list[str] | None = None,
36
+ min_severity: str = "warning",
37
+ ) -> NotificationChannel:
38
+ body: dict[str, Any] = {
39
+ "channel_type": channel_type,
40
+ "destination": destination,
41
+ "min_severity": min_severity,
42
+ }
43
+ if events:
44
+ body["events"] = events
45
+ return _to_channel(self._client.post("/v1/notifications/channels", json=body))
46
+
47
+ def verify(self, channel_id: str, code: str | None = None) -> dict[str, Any]:
48
+ body: dict[str, Any] = {}
49
+ if code:
50
+ body["code"] = code
51
+ return self._client.post(f"/v1/notifications/channels/{channel_id}/verify", json=body)
52
+
53
+ def test(self, channel_id: str) -> dict[str, Any]:
54
+ return self._client.post(f"/v1/notifications/channels/{channel_id}/test")
55
+
56
+ def delete(self, channel_id: str) -> dict[str, Any]:
57
+ return self._client.delete(f"/v1/notifications/channels/{channel_id}")
getstack/passports.py ADDED
@@ -0,0 +1,359 @@
1
+ """Passport management with mission context manager and continuous rotation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ import threading
8
+ from contextlib import contextmanager
9
+ from typing import Any, Generator
10
+
11
+ from .client import HttpClient
12
+ from .types import Passport, CheckpointResult, CheckoutResult, PassportReport, ToolCall
13
+
14
+
15
+ def _parse_duration(s: str) -> int:
16
+ """Parse a duration string like '30m', '2h', '300' to seconds."""
17
+ match = re.match(r"^(\d+)(s|m|h)$", s)
18
+ if match:
19
+ num, unit = int(match.group(1)), match.group(2)
20
+ return num * {"s": 1, "m": 60, "h": 3600}[unit]
21
+ return int(s)
22
+
23
+
24
+ def _to_passport(data: dict[str, Any]) -> Passport:
25
+ return Passport(
26
+ token=data["token"],
27
+ jti=data["jti"],
28
+ expires_at=data["expires_at"],
29
+ accountability=data.get("accountability"),
30
+ )
31
+
32
+
33
+ class Mission:
34
+ """A bounded accountability window for an agent mission.
35
+
36
+ Tracks tool calls, submits checkpoints on schedule, and checks out
37
+ automatically when the context manager exits.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ service: PassportService,
43
+ agent_id: str,
44
+ intent: str,
45
+ services: list[str],
46
+ will_delegate: bool = False,
47
+ estimated_duration: str | None = None,
48
+ checkpoint_interval: str | None = None,
49
+ ):
50
+ self._service = service
51
+ self._agent_id = agent_id
52
+ self._intent = intent
53
+ self._declared_services = services
54
+ self._will_delegate = will_delegate
55
+ self._estimated_duration = estimated_duration
56
+ self._checkpoint_interval = checkpoint_interval
57
+
58
+ self.passport: Passport | None = None
59
+ self.review_status: str | None = None
60
+ self.result: CheckoutResult | None = None
61
+ self.rotation_count: int = 0
62
+
63
+ self._tool_calls: list[ToolCall] = []
64
+ self._services_used: set[str] = set()
65
+ self._delegated_to: list[str] = []
66
+ self._last_checkpoint_index: int = 0
67
+ self._timer: threading.Timer | None = None
68
+ self._timer_interval: float | None = None
69
+ self._rotation_start: float = 0
70
+ self._rotation_interval: float | None = None
71
+
72
+ @property
73
+ def should_rotate(self) -> bool:
74
+ """True when the rotation interval has elapsed. Check at natural breakpoints."""
75
+ if self._rotation_interval is None:
76
+ return False
77
+ return time.monotonic() - self._rotation_start >= self._rotation_interval
78
+
79
+ def log(self, service: str, method: str, target: str | None = None) -> None:
80
+ """Log a tool call. Call this for each action the agent takes."""
81
+ self._tool_calls.append(ToolCall(service=service, method=method, target=target))
82
+ self._services_used.add(service)
83
+
84
+ def delegate(self, child_agent_id: str) -> None:
85
+ """Record that delegation occurred to a child agent."""
86
+ self._delegated_to.append(child_agent_id)
87
+
88
+ def _enter(self) -> None:
89
+ """Issue passport and start checkpoint timer."""
90
+ self.passport = self._service.issue(
91
+ agent_id=self._agent_id,
92
+ intent=self._intent,
93
+ services=self._declared_services,
94
+ will_delegate=self._will_delegate,
95
+ estimated_duration=self._estimated_duration,
96
+ checkpoint_interval=self._checkpoint_interval,
97
+ )
98
+ self._rotation_start = time.monotonic()
99
+
100
+ # Start checkpoint timer for enforced mode
101
+ if self._checkpoint_interval:
102
+ self._timer_interval = _parse_duration(self._checkpoint_interval)
103
+ elif self.passport.accountability == "enforced":
104
+ self._timer_interval = 300 # 5 min default
105
+ else:
106
+ self._timer_interval = None
107
+
108
+ if self._timer_interval:
109
+ self._schedule_checkpoint()
110
+
111
+ def _schedule_checkpoint(self) -> None:
112
+ """Schedule the next automatic checkpoint."""
113
+ if self._timer_interval:
114
+ # Submit slightly before the interval to ensure we don't miss it
115
+ delay = max(self._timer_interval - 5, 10)
116
+ self._timer = threading.Timer(delay, self._auto_checkpoint)
117
+ self._timer.daemon = True
118
+ self._timer.start()
119
+
120
+ def _auto_checkpoint(self) -> None:
121
+ """Submit checkpoint and reschedule."""
122
+ try:
123
+ self._submit_checkpoint()
124
+ except Exception:
125
+ pass # Don't crash the agent on checkpoint failure
126
+ self._schedule_checkpoint()
127
+
128
+ def _submit_checkpoint(self) -> None:
129
+ """Submit accumulated activity as a checkpoint."""
130
+ if not self.passport:
131
+ return
132
+
133
+ new_calls = self._tool_calls[self._last_checkpoint_index:]
134
+ self._service.checkpoint(
135
+ self.passport.jti,
136
+ services_used=list(self._services_used),
137
+ actions_count=len(new_calls),
138
+ tool_calls=[tc.to_dict() for tc in new_calls] if new_calls else None,
139
+ delegated_to=self._delegated_to if self._delegated_to else None,
140
+ )
141
+ self._last_checkpoint_index = len(self._tool_calls)
142
+
143
+ def _exit(self, error: Exception | None = None) -> None:
144
+ """Stop timer and submit checkout."""
145
+ if self._timer:
146
+ self._timer.cancel()
147
+ self._timer = None
148
+
149
+ if not self.passport:
150
+ return
151
+
152
+ summary = None
153
+ if error:
154
+ summary = f"Mission failed: {type(error).__name__}: {error}"
155
+
156
+ try:
157
+ self.result = self._service.checkout(
158
+ self.passport.jti,
159
+ services_used=list(self._services_used),
160
+ actions_count=len(self._tool_calls),
161
+ tool_calls=[tc.to_dict() for tc in self._tool_calls] if self._tool_calls else None,
162
+ summary=summary,
163
+ delegated_to=self._delegated_to if self._delegated_to else None,
164
+ )
165
+ self.review_status = self.result.review_status
166
+ except Exception:
167
+ pass # Best-effort checkout
168
+
169
+ def __enter__(self) -> Mission:
170
+ self._enter()
171
+ return self
172
+
173
+ def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: Any) -> bool:
174
+ self._exit(error=exc_val if exc_type else None)
175
+ return False # Don't suppress exceptions
176
+
177
+
178
+ class PassportService:
179
+ def __init__(self, client: HttpClient):
180
+ self._client = client
181
+
182
+ def issue(
183
+ self,
184
+ agent_id: str,
185
+ intent: str | None = None,
186
+ services: list[str] | None = None,
187
+ will_delegate: bool = False,
188
+ estimated_duration: str | None = None,
189
+ checkpoint_interval: str | None = None,
190
+ ttl_seconds: int | None = None,
191
+ scopes: list[dict[str, Any]] | None = None,
192
+ ) -> Passport:
193
+ body: dict[str, Any] = {"agent_id": agent_id}
194
+ if scopes:
195
+ body["scopes"] = scopes
196
+ if ttl_seconds:
197
+ body["ttl_seconds"] = ttl_seconds
198
+ if intent or services:
199
+ body["intent"] = {
200
+ "summary": intent or "",
201
+ "services": services or [],
202
+ "will_delegate": will_delegate,
203
+ }
204
+ if estimated_duration:
205
+ body["intent"]["estimated_duration_seconds"] = _parse_duration(estimated_duration)
206
+ if checkpoint_interval:
207
+ body["checkpoint_interval_seconds"] = _parse_duration(checkpoint_interval)
208
+ return _to_passport(self._client.post("/v1/passports/issue", json=body))
209
+
210
+ def checkpoint(
211
+ self,
212
+ passport_jti: str,
213
+ services_used: list[str],
214
+ actions_count: int,
215
+ tool_calls: list[dict[str, Any]] | None = None,
216
+ summary: str | None = None,
217
+ delegated_to: list[str] | None = None,
218
+ ) -> CheckpointResult:
219
+ body: dict[str, Any] = {
220
+ "services_used": services_used,
221
+ "actions_count": actions_count,
222
+ }
223
+ if tool_calls:
224
+ body["tool_calls"] = tool_calls
225
+ if summary:
226
+ body["summary"] = summary
227
+ if delegated_to:
228
+ body["delegated_to"] = delegated_to
229
+ data = self._client.post(f"/v1/passports/{passport_jti}/checkpoint", json=body)
230
+ return CheckpointResult(
231
+ checkpoint_id=data["checkpoint_id"],
232
+ passport_jti=data["passport_jti"],
233
+ new_expires_at=data.get("new_expires_at"),
234
+ flags=data.get("flags"),
235
+ )
236
+
237
+ def checkout(
238
+ self,
239
+ passport_jti: str,
240
+ services_used: list[str],
241
+ actions_count: int,
242
+ tool_calls: list[dict[str, Any]] | None = None,
243
+ summary: str | None = None,
244
+ delegated_to: list[str] | None = None,
245
+ ) -> CheckoutResult:
246
+ body: dict[str, Any] = {
247
+ "services_used": services_used,
248
+ "actions_count": actions_count,
249
+ }
250
+ if tool_calls:
251
+ body["tool_calls"] = tool_calls
252
+ if summary:
253
+ body["summary"] = summary
254
+ if delegated_to:
255
+ body["delegated_to"] = delegated_to
256
+ data = self._client.post(f"/v1/passports/{passport_jti}/checkout", json=body)
257
+ return CheckoutResult(
258
+ checkout_id=data["checkout_id"],
259
+ passport_jti=data["passport_jti"],
260
+ review_status=data["review_status"],
261
+ flags=data.get("flags"),
262
+ )
263
+
264
+ def report(self, passport_jti: str) -> PassportReport:
265
+ data = self._client.get(f"/v1/passports/{passport_jti}/report")
266
+ return PassportReport(**data)
267
+
268
+ def verify(self, token: str) -> dict[str, Any]:
269
+ return self._client.post("/v1/passports/verify", json={"token": token})
270
+
271
+ def revoke(self, jti: str, reason: str | None = None) -> dict[str, Any]:
272
+ return self._client.post("/v1/passports/revoke", json={"jti": jti, "reason": reason})
273
+
274
+ def refresh(self, token: str, ttl_seconds: int | None = None) -> Passport:
275
+ body: dict[str, Any] = {"token": token}
276
+ if ttl_seconds:
277
+ body["ttl_seconds"] = ttl_seconds
278
+ return _to_passport(self._client.post("/v1/passports/refresh", json=body))
279
+
280
+ def list_active(self, agent_id: str | None = None, session_id: str | None = None) -> list[dict[str, Any]]:
281
+ params: dict[str, str] = {}
282
+ if agent_id:
283
+ params["agent_id"] = agent_id
284
+ if session_id:
285
+ params["session_id"] = session_id
286
+ return self._client.get("/v1/passports/active", params=params or None)
287
+
288
+ @contextmanager
289
+ def mission(
290
+ self,
291
+ agent_id: str,
292
+ intent: str,
293
+ services: list[str],
294
+ will_delegate: bool = False,
295
+ estimated_duration: str | None = None,
296
+ checkpoint_interval: str | None = None,
297
+ ) -> Generator[Mission, None, None]:
298
+ """Context manager for a single bounded mission."""
299
+ m = Mission(
300
+ self, agent_id, intent, services,
301
+ will_delegate=will_delegate,
302
+ estimated_duration=estimated_duration,
303
+ checkpoint_interval=checkpoint_interval,
304
+ )
305
+ m._enter()
306
+ try:
307
+ yield m
308
+ except Exception as e:
309
+ m._exit(error=e)
310
+ raise
311
+ else:
312
+ m._exit()
313
+
314
+ def continuous_mission(
315
+ self,
316
+ agent_id: str,
317
+ intent: str,
318
+ services: list[str],
319
+ rotation_interval: str = "4h",
320
+ will_delegate: bool = False,
321
+ checkpoint_interval: str | None = None,
322
+ ) -> Generator[Mission, None, None]:
323
+ """Generator yielding successive Mission objects with automatic rotation.
324
+
325
+ Each iteration issues a new passport, runs checkpoints, and checks out
326
+ when the rotation interval elapses (signaled via mission.should_rotate).
327
+
328
+ Usage::
329
+
330
+ for mission in stack.passports.continuous_mission(
331
+ agent_id="agt_...",
332
+ intent="Monitor Slack",
333
+ services=["slack"],
334
+ rotation_interval="4h",
335
+ ):
336
+ while not mission.should_rotate:
337
+ event = wait_for_event()
338
+ mission.log("slack", "read_message")
339
+ process(event)
340
+ """
341
+ rotation_secs = _parse_duration(rotation_interval)
342
+ rotation_count = 0
343
+
344
+ while True:
345
+ m = Mission(
346
+ self, agent_id, intent, services,
347
+ will_delegate=will_delegate,
348
+ checkpoint_interval=checkpoint_interval,
349
+ )
350
+ m.rotation_count = rotation_count
351
+ m._rotation_interval = rotation_secs
352
+
353
+ m._enter()
354
+ try:
355
+ yield m
356
+ finally:
357
+ m._exit()
358
+
359
+ rotation_count += 1
getstack/py.typed ADDED
File without changes
getstack/reviews.py ADDED
@@ -0,0 +1,54 @@
1
+ """Review queue management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+ from .types import ReviewQueue, ReviewQueueItem, ReviewDecisionResult
9
+
10
+
11
+ class ReviewService:
12
+ def __init__(self, client: HttpClient):
13
+ self._client = client
14
+
15
+ def list(self, status: str = "flagged", page: int = 1, limit: int = 20) -> ReviewQueue:
16
+ data = self._client.get("/v1/passports/reviews", params={
17
+ "status": status,
18
+ "page": str(page),
19
+ "limit": str(limit),
20
+ })
21
+ items = [
22
+ ReviewQueueItem(
23
+ checkout_id=item["checkout_id"],
24
+ passport_jti=item["passport_jti"],
25
+ agent_id=item["agent_id"],
26
+ agent_name=item["agent_name"],
27
+ intent_summary=item["intent_summary"],
28
+ review_status=item["review_status"],
29
+ flags=item.get("flags", []),
30
+ submitted_at=item["submitted_at"],
31
+ )
32
+ for item in data["items"]
33
+ ]
34
+ return ReviewQueue(items=items, total=data["total"])
35
+
36
+ def decide(
37
+ self,
38
+ checkout_id: str,
39
+ decision: str,
40
+ notes: str | None = None,
41
+ block_future: bool = False,
42
+ ) -> ReviewDecisionResult:
43
+ body: dict[str, Any] = {"decision": decision}
44
+ if notes:
45
+ body["notes"] = notes
46
+ if block_future:
47
+ body["block_future"] = True
48
+ data = self._client.post(f"/v1/passports/reviews/{checkout_id}/decide", json=body)
49
+ return ReviewDecisionResult(
50
+ review_id=data["review_id"],
51
+ agent_id=data["agent_id"],
52
+ decision=data["decision"],
53
+ passport_blocked=data["passport_blocked"],
54
+ )
getstack/services.py ADDED
@@ -0,0 +1,41 @@
1
+ """Service management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .client import HttpClient
8
+
9
+
10
+ class ServiceService:
11
+ def __init__(self, client: HttpClient):
12
+ self._client = client
13
+
14
+ def list(self) -> list[dict[str, Any]]:
15
+ return self._client.get("/v1/services/connections")
16
+
17
+ def list_available(self) -> list[dict[str, Any]]:
18
+ return self._client.get("/v1/services")
19
+
20
+ def list_templates(self) -> list[dict[str, Any]]:
21
+ return self._client.get("/v1/services/templates")
22
+
23
+ def connect_custom(
24
+ self,
25
+ name: str,
26
+ credential: str | dict[str, str],
27
+ description: str | None = None,
28
+ scopes: list[str] | None = None,
29
+ ) -> dict[str, Any]:
30
+ body: dict[str, Any] = {"name": name, "credential": credential}
31
+ if description:
32
+ body["description"] = description
33
+ if scopes:
34
+ body["scopes"] = scopes
35
+ return self._client.post("/v1/services/custom", json=body)
36
+
37
+ def verify(self, connection_id: str) -> dict[str, Any]:
38
+ return self._client.post(f"/v1/services/{connection_id}/verify")
39
+
40
+ def disconnect(self, connection_id: str) -> dict[str, Any]:
41
+ return self._client.delete(f"/v1/services/connections/{connection_id}")
getstack/types.py ADDED
@@ -0,0 +1,156 @@
1
+ """STACK SDK type definitions."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class Agent:
10
+ id: str
11
+ operator_id: str
12
+ name: str
13
+ description: str | None
14
+ status: str
15
+ accountability_mode: str
16
+ on_warning: str
17
+ on_critical: str
18
+ passport_blocked: bool
19
+ passport_blocked_reason: str | None
20
+ passport_blocked_at: str | None
21
+ created_at: str
22
+ updated_at: str
23
+
24
+
25
+ @dataclass
26
+ class Passport:
27
+ token: str
28
+ jti: str
29
+ expires_at: str
30
+ accountability: str | None = None
31
+
32
+
33
+ @dataclass
34
+ class CheckpointResult:
35
+ checkpoint_id: str
36
+ passport_jti: str
37
+ new_expires_at: str | None = None
38
+ flags: list[str] | None = None
39
+
40
+
41
+ @dataclass
42
+ class CheckoutResult:
43
+ checkout_id: str
44
+ passport_jti: str
45
+ review_status: str # clean | flagged | blocked
46
+ flags: list[dict[str, str]] | None = None
47
+
48
+
49
+ @dataclass
50
+ class ReviewFlag:
51
+ type: str
52
+ severity: str
53
+ message: str
54
+
55
+
56
+ @dataclass
57
+ class PassportReport:
58
+ passport: dict[str, Any]
59
+ intent: dict[str, Any] | None
60
+ checkpoints: list[dict[str, Any]]
61
+ checkout: dict[str, Any] | None
62
+ review: dict[str, Any] | None
63
+
64
+
65
+ @dataclass
66
+ class ReviewQueueItem:
67
+ checkout_id: str
68
+ passport_jti: str
69
+ agent_id: str
70
+ agent_name: str
71
+ intent_summary: str
72
+ review_status: str
73
+ flags: list[dict[str, str]]
74
+ submitted_at: str
75
+
76
+
77
+ @dataclass
78
+ class ReviewQueue:
79
+ items: list[ReviewQueueItem]
80
+ total: int
81
+
82
+
83
+ @dataclass
84
+ class ReviewDecisionResult:
85
+ review_id: str
86
+ agent_id: str
87
+ decision: str
88
+ passport_blocked: bool
89
+
90
+
91
+ @dataclass
92
+ class Credential:
93
+ provider: str
94
+ credential: str | None = None
95
+ credentials: dict[str, str] | None = None
96
+
97
+
98
+ @dataclass
99
+ class Service:
100
+ id: str
101
+ provider: str
102
+ name: str
103
+ description: str
104
+ available: bool = True
105
+
106
+
107
+ @dataclass
108
+ class ServiceConnection:
109
+ id: str
110
+ provider: str
111
+ status: str
112
+ connected_at: str
113
+
114
+
115
+ @dataclass
116
+ class Dropoff:
117
+ id: str
118
+ from_agent_id: str
119
+ to_agent_id: str
120
+ status: str
121
+ created_at: str
122
+ expires_at: str
123
+
124
+
125
+ @dataclass
126
+ class NotificationChannel:
127
+ id: str
128
+ channel_type: str
129
+ destination: str
130
+ events: list[str]
131
+ min_severity: str
132
+ active: bool
133
+ verified: bool
134
+
135
+
136
+ @dataclass
137
+ class AuditEntry:
138
+ entry_id: str
139
+ action: str
140
+ outcome: str
141
+ timestamp: int
142
+ agent_id: str
143
+
144
+
145
+ @dataclass
146
+ class ToolCall:
147
+ """A single tool call logged during a mission."""
148
+ service: str
149
+ method: str
150
+ target: str | None = None
151
+
152
+ def to_dict(self) -> dict[str, Any]:
153
+ d: dict[str, Any] = {"service": self.service, "method": self.method}
154
+ if self.target:
155
+ d["target"] = self.target
156
+ return d