stacklink-sdk 0.2.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.
- stacklink/__init__.py +42 -0
- stacklink/api_keys.py +18 -0
- stacklink/artifacts.py +9 -0
- stacklink/auth_configs.py +29 -0
- stacklink/billing.py +43 -0
- stacklink/claude_agent.py +29 -0
- stacklink/client.py +292 -0
- stacklink/connected_accounts.py +95 -0
- stacklink/errors.py +15 -0
- stacklink/executions.py +45 -0
- stacklink/knowledge.py +24 -0
- stacklink/logs.py +29 -0
- stacklink/mcp.py +76 -0
- stacklink/memory.py +235 -0
- stacklink/openai_agents.py +40 -0
- stacklink/openai_memory.py +432 -0
- stacklink/projects.py +20 -0
- stacklink/sessions.py +223 -0
- stacklink/tools.py +144 -0
- stacklink/triggers.py +27 -0
- stacklink/types.py +290 -0
- stacklink/webhooks.py +29 -0
- stacklink/workbench.py +1090 -0
- stacklink_sdk-0.2.0.dist-info/METADATA +71 -0
- stacklink_sdk-0.2.0.dist-info/RECORD +28 -0
- stacklink_sdk-0.2.0.dist-info/WHEEL +5 -0
- stacklink_sdk-0.2.0.dist-info/licenses/LICENSE +9 -0
- stacklink_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .errors import StacklinkError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StacklinkMemoryLifecycleError(RuntimeError):
|
|
13
|
+
def __init__(self, message: str, *, phase: str, cause: Exception | None = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.phase = phase
|
|
16
|
+
self.__cause__ = cause
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def with_stacklink_memory(
|
|
20
|
+
openai_client: Any,
|
|
21
|
+
*,
|
|
22
|
+
session: Any,
|
|
23
|
+
application_key: str = "openai",
|
|
24
|
+
capture: str = "completed_turns",
|
|
25
|
+
availability_mode: str = "continue",
|
|
26
|
+
context_options: dict[str, Any] | None = None,
|
|
27
|
+
consent: dict[str, Any] | None = None,
|
|
28
|
+
metadata: dict[str, Any] | None = None,
|
|
29
|
+
on_diagnostic: Callable[[dict[str, Any]], None] | None = None,
|
|
30
|
+
):
|
|
31
|
+
"""Add automatic Stacklink Memory recall and completed-turn capture to OpenAI.
|
|
32
|
+
|
|
33
|
+
The returned object preserves the normal ``responses.create`` and
|
|
34
|
+
``chat.completions.create`` call shapes. Both sync and async OpenAI clients
|
|
35
|
+
are supported. Streaming turns are captured only after the stream reaches a
|
|
36
|
+
successful terminal event.
|
|
37
|
+
"""
|
|
38
|
+
lifecycle = _MemoryLifecycle(
|
|
39
|
+
session=session,
|
|
40
|
+
application_key=application_key,
|
|
41
|
+
capture=capture,
|
|
42
|
+
availability_mode=availability_mode,
|
|
43
|
+
context_options=context_options or {},
|
|
44
|
+
consent=consent,
|
|
45
|
+
metadata=metadata or {},
|
|
46
|
+
on_diagnostic=on_diagnostic,
|
|
47
|
+
)
|
|
48
|
+
responses = _CreateResourceProxy(
|
|
49
|
+
openai_client.responses,
|
|
50
|
+
lambda request, original: _responses_create(lifecycle, request, original),
|
|
51
|
+
)
|
|
52
|
+
completions = _CreateResourceProxy(
|
|
53
|
+
openai_client.chat.completions,
|
|
54
|
+
lambda request, original: _chat_create(lifecycle, request, original),
|
|
55
|
+
)
|
|
56
|
+
chat = _AttributeProxy(openai_client.chat, {"completions": completions})
|
|
57
|
+
return _AttributeProxy(openai_client, {"responses": responses, "chat": chat})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _MemoryLifecycle:
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
session: Any,
|
|
65
|
+
application_key: str,
|
|
66
|
+
capture: str,
|
|
67
|
+
availability_mode: str,
|
|
68
|
+
context_options: dict[str, Any],
|
|
69
|
+
consent: dict[str, Any] | None,
|
|
70
|
+
metadata: dict[str, Any],
|
|
71
|
+
on_diagnostic: Callable[[dict[str, Any]], None] | None,
|
|
72
|
+
):
|
|
73
|
+
if capture not in {"completed_turns", "off"}:
|
|
74
|
+
raise ValueError("capture must be 'completed_turns' or 'off'.")
|
|
75
|
+
if availability_mode not in {"continue", "throw"}:
|
|
76
|
+
raise ValueError("availability_mode must be 'continue' or 'throw'.")
|
|
77
|
+
self.session = session
|
|
78
|
+
self.application_key = application_key
|
|
79
|
+
self.capture = capture
|
|
80
|
+
self.availability_mode = availability_mode
|
|
81
|
+
self.context_options = context_options
|
|
82
|
+
self.consent = consent
|
|
83
|
+
self.metadata = metadata
|
|
84
|
+
self.on_diagnostic = on_diagnostic
|
|
85
|
+
|
|
86
|
+
def recall(self, query: str) -> str:
|
|
87
|
+
if not query.strip():
|
|
88
|
+
return ""
|
|
89
|
+
try:
|
|
90
|
+
response = self.session.memory.context({**self.context_options, "query": query})
|
|
91
|
+
prompt_text = response.get("promptText") if isinstance(response, dict) else None
|
|
92
|
+
if not isinstance(prompt_text, str):
|
|
93
|
+
raise StacklinkMemoryLifecycleError(
|
|
94
|
+
"Stacklink Memory context did not include promptText.",
|
|
95
|
+
phase="recall",
|
|
96
|
+
)
|
|
97
|
+
return prompt_text
|
|
98
|
+
except Exception as error:
|
|
99
|
+
self._handle_failure("recall", error)
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
def capture_turn(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
user_text: str,
|
|
106
|
+
assistant_text: str,
|
|
107
|
+
framework: str,
|
|
108
|
+
response_id: str | None,
|
|
109
|
+
) -> None:
|
|
110
|
+
if self.capture == "off" or not user_text.strip() or not assistant_text.strip():
|
|
111
|
+
return
|
|
112
|
+
content = json.dumps(
|
|
113
|
+
{
|
|
114
|
+
"schemaVersion": "stacklink.conversation.turn.v1",
|
|
115
|
+
"user": {"role": "user", "content": user_text},
|
|
116
|
+
"assistant": {"role": "assistant", "content": assistant_text},
|
|
117
|
+
},
|
|
118
|
+
separators=(",", ":"),
|
|
119
|
+
ensure_ascii=False,
|
|
120
|
+
)
|
|
121
|
+
body: dict[str, Any] = {
|
|
122
|
+
"applicationKey": self.application_key,
|
|
123
|
+
"turnId": response_id or str(uuid.uuid4()),
|
|
124
|
+
"content": content,
|
|
125
|
+
"metadata": {
|
|
126
|
+
**self.metadata,
|
|
127
|
+
"framework": framework,
|
|
128
|
+
"responseId": response_id,
|
|
129
|
+
"captureMode": "completed_turns",
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
if self.consent is not None:
|
|
133
|
+
body["consent"] = self.consent
|
|
134
|
+
try:
|
|
135
|
+
self.session.memory.record_conversation_turn(body)
|
|
136
|
+
except Exception as error:
|
|
137
|
+
self._handle_failure("capture", error)
|
|
138
|
+
|
|
139
|
+
def _handle_failure(self, phase: str, error: Exception) -> None:
|
|
140
|
+
if self.availability_mode == "continue" and _is_availability_failure(error):
|
|
141
|
+
if self.on_diagnostic is not None:
|
|
142
|
+
self.on_diagnostic(
|
|
143
|
+
{
|
|
144
|
+
"phase": phase,
|
|
145
|
+
"code": "memory_unavailable",
|
|
146
|
+
"message": (
|
|
147
|
+
"The model call continued without Stacklink Memory context."
|
|
148
|
+
if phase == "recall"
|
|
149
|
+
else "The model response completed, but Stacklink Memory capture is unavailable."
|
|
150
|
+
),
|
|
151
|
+
"error": error,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
if isinstance(error, StacklinkMemoryLifecycleError):
|
|
156
|
+
raise error
|
|
157
|
+
raise StacklinkMemoryLifecycleError(
|
|
158
|
+
(
|
|
159
|
+
"Stacklink Memory context could not be assembled."
|
|
160
|
+
if phase == "recall"
|
|
161
|
+
else "The completed turn could not be accepted by Stacklink Memory."
|
|
162
|
+
),
|
|
163
|
+
phase=phase,
|
|
164
|
+
cause=error,
|
|
165
|
+
) from error
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _responses_create(lifecycle: _MemoryLifecycle, request: dict[str, Any], original: Callable[..., Any]):
|
|
169
|
+
user_text = _responses_user_text(request.get("input"))
|
|
170
|
+
prompt_text = lifecycle.recall(user_text)
|
|
171
|
+
next_request = dict(request)
|
|
172
|
+
if prompt_text:
|
|
173
|
+
memory_instructions = f"Stacklink Memory context\n\n{prompt_text}"
|
|
174
|
+
existing = next_request.get("instructions")
|
|
175
|
+
next_request["instructions"] = (
|
|
176
|
+
f"{existing}\n\n{memory_instructions}"
|
|
177
|
+
if isinstance(existing, str) and existing
|
|
178
|
+
else memory_instructions
|
|
179
|
+
)
|
|
180
|
+
response = original(**next_request)
|
|
181
|
+
|
|
182
|
+
def transform(value: Any):
|
|
183
|
+
if request.get("stream") is True:
|
|
184
|
+
observer = _ResponsesStreamObserver(lifecycle, user_text)
|
|
185
|
+
return _wrap_stream(value, observer)
|
|
186
|
+
if _value(value, "status") == "completed":
|
|
187
|
+
lifecycle.capture_turn(
|
|
188
|
+
user_text=user_text,
|
|
189
|
+
assistant_text=_string(_value(value, "output_text")),
|
|
190
|
+
framework="openai-python-responses",
|
|
191
|
+
response_id=_optional_string(_value(value, "id")),
|
|
192
|
+
)
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
return _transform_possible_awaitable(response, transform)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _chat_create(lifecycle: _MemoryLifecycle, request: dict[str, Any], original: Callable[..., Any]):
|
|
199
|
+
user_text = _chat_user_text(request.get("messages"))
|
|
200
|
+
prompt_text = lifecycle.recall(user_text)
|
|
201
|
+
next_request = dict(request)
|
|
202
|
+
if prompt_text:
|
|
203
|
+
messages = list(next_request.get("messages") or [])
|
|
204
|
+
insertion = len(messages)
|
|
205
|
+
for index, message in enumerate(messages):
|
|
206
|
+
role = _value(message, "role")
|
|
207
|
+
if role not in {"system", "developer"}:
|
|
208
|
+
insertion = index
|
|
209
|
+
break
|
|
210
|
+
messages.insert(insertion, {"role": "system", "content": f"Stacklink Memory context\n\n{prompt_text}"})
|
|
211
|
+
next_request["messages"] = messages
|
|
212
|
+
response = original(**next_request)
|
|
213
|
+
|
|
214
|
+
def transform(value: Any):
|
|
215
|
+
if request.get("stream") is True:
|
|
216
|
+
observer = _ChatStreamObserver(lifecycle, user_text)
|
|
217
|
+
return _wrap_stream(value, observer)
|
|
218
|
+
choices = _value(value, "choices")
|
|
219
|
+
choice = choices[0] if isinstance(choices, list) and choices else None
|
|
220
|
+
finish_reason = _value(choice, "finish_reason")
|
|
221
|
+
if finish_reason in {"stop", "length"}:
|
|
222
|
+
lifecycle.capture_turn(
|
|
223
|
+
user_text=user_text,
|
|
224
|
+
assistant_text=_string(_value(_value(choice, "message"), "content")),
|
|
225
|
+
framework="openai-python-chat-completions",
|
|
226
|
+
response_id=_optional_string(_value(value, "id")),
|
|
227
|
+
)
|
|
228
|
+
return value
|
|
229
|
+
|
|
230
|
+
return _transform_possible_awaitable(response, transform)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def _await_and_transform(awaitable: Any, transform: Callable[[Any], Any]):
|
|
234
|
+
return transform(await awaitable)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _transform_possible_awaitable(value: Any, transform: Callable[[Any], Any]):
|
|
238
|
+
if inspect.isawaitable(value):
|
|
239
|
+
return _await_and_transform(value, transform)
|
|
240
|
+
return transform(value)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _wrap_stream(source: Any, observer: "_StreamObserver"):
|
|
244
|
+
if hasattr(source, "__aiter__"):
|
|
245
|
+
return _ObservedAsyncIterator(source, observer)
|
|
246
|
+
if hasattr(source, "__iter__"):
|
|
247
|
+
return _ObservedIterator(source, observer)
|
|
248
|
+
raise TypeError("OpenAI returned a stream without a sync or async iterator contract.")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _StreamObserver:
|
|
252
|
+
def item(self, value: Any) -> None:
|
|
253
|
+
raise NotImplementedError
|
|
254
|
+
|
|
255
|
+
def complete(self) -> None:
|
|
256
|
+
raise NotImplementedError
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class _ResponsesStreamObserver(_StreamObserver):
|
|
260
|
+
def __init__(self, lifecycle: _MemoryLifecycle, user_text: str):
|
|
261
|
+
self.lifecycle = lifecycle
|
|
262
|
+
self.user_text = user_text
|
|
263
|
+
self.assistant_text = ""
|
|
264
|
+
self.response_id: str | None = None
|
|
265
|
+
self.completed = False
|
|
266
|
+
|
|
267
|
+
def item(self, event: Any) -> None:
|
|
268
|
+
event_type = _value(event, "type")
|
|
269
|
+
if event_type == "response.output_text.delta":
|
|
270
|
+
self.assistant_text += _string(_value(event, "delta"))
|
|
271
|
+
elif event_type == "response.completed":
|
|
272
|
+
self.completed = True
|
|
273
|
+
response = _value(event, "response")
|
|
274
|
+
self.response_id = _optional_string(_value(response, "id"))
|
|
275
|
+
if not self.assistant_text:
|
|
276
|
+
self.assistant_text = _string(_value(response, "output_text"))
|
|
277
|
+
|
|
278
|
+
def complete(self) -> None:
|
|
279
|
+
if self.completed:
|
|
280
|
+
self.lifecycle.capture_turn(
|
|
281
|
+
user_text=self.user_text,
|
|
282
|
+
assistant_text=self.assistant_text,
|
|
283
|
+
framework="openai-python-responses",
|
|
284
|
+
response_id=self.response_id,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _ChatStreamObserver(_StreamObserver):
|
|
289
|
+
def __init__(self, lifecycle: _MemoryLifecycle, user_text: str):
|
|
290
|
+
self.lifecycle = lifecycle
|
|
291
|
+
self.user_text = user_text
|
|
292
|
+
self.assistant_text = ""
|
|
293
|
+
self.response_id: str | None = None
|
|
294
|
+
self.finish_reason: str | None = None
|
|
295
|
+
|
|
296
|
+
def item(self, chunk: Any) -> None:
|
|
297
|
+
self.response_id = self.response_id or _optional_string(_value(chunk, "id"))
|
|
298
|
+
choices = _value(chunk, "choices")
|
|
299
|
+
choice = choices[0] if isinstance(choices, list) and choices else None
|
|
300
|
+
self.assistant_text += _string(_value(_value(choice, "delta"), "content"))
|
|
301
|
+
self.finish_reason = _optional_string(_value(choice, "finish_reason")) or self.finish_reason
|
|
302
|
+
|
|
303
|
+
def complete(self) -> None:
|
|
304
|
+
if self.finish_reason in {"stop", "length"}:
|
|
305
|
+
self.lifecycle.capture_turn(
|
|
306
|
+
user_text=self.user_text,
|
|
307
|
+
assistant_text=self.assistant_text,
|
|
308
|
+
framework="openai-python-chat-completions",
|
|
309
|
+
response_id=self.response_id,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class _ObservedIterator(Iterator[Any]):
|
|
314
|
+
def __init__(self, source: Any, observer: _StreamObserver):
|
|
315
|
+
self.source = source
|
|
316
|
+
self.iterator = iter(source)
|
|
317
|
+
self.observer = observer
|
|
318
|
+
self.finished = False
|
|
319
|
+
|
|
320
|
+
def __next__(self):
|
|
321
|
+
try:
|
|
322
|
+
item = next(self.iterator)
|
|
323
|
+
except StopIteration:
|
|
324
|
+
if not self.finished:
|
|
325
|
+
self.finished = True
|
|
326
|
+
self.observer.complete()
|
|
327
|
+
raise
|
|
328
|
+
self.observer.item(item)
|
|
329
|
+
return item
|
|
330
|
+
|
|
331
|
+
def __getattr__(self, name: str):
|
|
332
|
+
return getattr(self.source, name)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _ObservedAsyncIterator(AsyncIterator[Any]):
|
|
336
|
+
def __init__(self, source: Any, observer: _StreamObserver):
|
|
337
|
+
self.source = source
|
|
338
|
+
self.iterator = source.__aiter__()
|
|
339
|
+
self.observer = observer
|
|
340
|
+
self.finished = False
|
|
341
|
+
|
|
342
|
+
async def __anext__(self):
|
|
343
|
+
try:
|
|
344
|
+
item = await self.iterator.__anext__()
|
|
345
|
+
except StopAsyncIteration:
|
|
346
|
+
if not self.finished:
|
|
347
|
+
self.finished = True
|
|
348
|
+
self.observer.complete()
|
|
349
|
+
raise
|
|
350
|
+
self.observer.item(item)
|
|
351
|
+
return item
|
|
352
|
+
|
|
353
|
+
def __getattr__(self, name: str):
|
|
354
|
+
return getattr(self.source, name)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class _AttributeProxy:
|
|
358
|
+
def __init__(self, target: Any, overrides: dict[str, Any]):
|
|
359
|
+
self._target = target
|
|
360
|
+
self._overrides = overrides
|
|
361
|
+
|
|
362
|
+
def __getattr__(self, name: str):
|
|
363
|
+
if name in self._overrides:
|
|
364
|
+
return self._overrides[name]
|
|
365
|
+
return getattr(self._target, name)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class _CreateResourceProxy(_AttributeProxy):
|
|
369
|
+
def __init__(self, target: Any, create: Callable[[dict[str, Any], Callable[..., Any]], Any]):
|
|
370
|
+
super().__init__(target, {})
|
|
371
|
+
self._create = create
|
|
372
|
+
|
|
373
|
+
def create(self, **request: Any):
|
|
374
|
+
return self._create(request, self._target.create)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _responses_user_text(value: Any) -> str:
|
|
378
|
+
if isinstance(value, str):
|
|
379
|
+
return value
|
|
380
|
+
if not isinstance(value, list):
|
|
381
|
+
return ""
|
|
382
|
+
for item in reversed(value):
|
|
383
|
+
if _value(item, "role") != "user":
|
|
384
|
+
continue
|
|
385
|
+
content = _value(item, "content")
|
|
386
|
+
if isinstance(content, str):
|
|
387
|
+
return content
|
|
388
|
+
if isinstance(content, list):
|
|
389
|
+
return "\n".join(
|
|
390
|
+
_string(_value(part, "text"))
|
|
391
|
+
for part in content
|
|
392
|
+
if _value(part, "type") == "input_text"
|
|
393
|
+
)
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _chat_user_text(value: Any) -> str:
|
|
398
|
+
if not isinstance(value, list):
|
|
399
|
+
return ""
|
|
400
|
+
for message in reversed(value):
|
|
401
|
+
if _value(message, "role") != "user":
|
|
402
|
+
continue
|
|
403
|
+
content = _value(message, "content")
|
|
404
|
+
if isinstance(content, str):
|
|
405
|
+
return content
|
|
406
|
+
if isinstance(content, list):
|
|
407
|
+
return "\n".join(
|
|
408
|
+
_string(_value(part, "text"))
|
|
409
|
+
for part in content
|
|
410
|
+
if _value(part, "type") == "text"
|
|
411
|
+
)
|
|
412
|
+
return ""
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _is_availability_failure(error: Exception) -> bool:
|
|
416
|
+
if isinstance(error, StacklinkError) and error.status_code is not None:
|
|
417
|
+
return error.status_code == 408 or error.status_code >= 500
|
|
418
|
+
return isinstance(error, (ConnectionError, TimeoutError, OSError))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _value(value: Any, name: str):
|
|
422
|
+
if isinstance(value, dict):
|
|
423
|
+
return value.get(name)
|
|
424
|
+
return getattr(value, name, None)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _string(value: Any) -> str:
|
|
428
|
+
return value if isinstance(value, str) else ""
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _optional_string(value: Any) -> str | None:
|
|
432
|
+
return value if isinstance(value, str) and value else None
|
stacklink/projects.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProjectsResource:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self, **query):
|
|
11
|
+
return self.client.request("GET", "/v1/projects", query=query)
|
|
12
|
+
|
|
13
|
+
def get(self, project_id: str):
|
|
14
|
+
return self.client.request("GET", f"/v1/projects/{project_id}")
|
|
15
|
+
|
|
16
|
+
def create(self, body: dict[str, Any]):
|
|
17
|
+
return self.client.request("POST", "/v1/projects", json_body=body, registration=True)
|
|
18
|
+
|
|
19
|
+
def reset_auth_configs(self, project_id: str):
|
|
20
|
+
return self.client.request("POST", f"/v1/projects/{project_id}/auth-configs/reset", registration=True)
|
stacklink/sessions.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, TypedDict
|
|
4
|
+
|
|
5
|
+
from .memory import SessionMemoryResource
|
|
6
|
+
from .types import ProjectProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionToolDefinition(TypedDict):
|
|
10
|
+
name: str
|
|
11
|
+
description: str
|
|
12
|
+
inputSchema: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionTool:
|
|
16
|
+
def __init__(self, session: "Session", definition: SessionToolDefinition):
|
|
17
|
+
self.session = session
|
|
18
|
+
self.name = definition["name"]
|
|
19
|
+
self.description = definition.get("description", "")
|
|
20
|
+
self.parameters = definition.get("inputSchema", {})
|
|
21
|
+
|
|
22
|
+
def call(self, arguments: Mapping[str, Any] | None = None):
|
|
23
|
+
return self.session.client.mcp.call_session_tool_result(self.session.id, self.name, dict(arguments or {}))
|
|
24
|
+
|
|
25
|
+
def __call__(self, **arguments: Any):
|
|
26
|
+
return self.call(arguments)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Session:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
client,
|
|
33
|
+
session_id: str,
|
|
34
|
+
*,
|
|
35
|
+
session_data: dict[str, Any] | None = None,
|
|
36
|
+
tool_projection: dict[str, Any] | None = None,
|
|
37
|
+
):
|
|
38
|
+
self.client = client
|
|
39
|
+
self.id = session_id
|
|
40
|
+
self.data = dict(session_data or {})
|
|
41
|
+
self._tool_projection = dict(tool_projection or {})
|
|
42
|
+
self.memory = SessionMemoryResource(client.memory, session_id)
|
|
43
|
+
self.sandbox = client.workbench.bind(session_id)
|
|
44
|
+
|
|
45
|
+
def get(self):
|
|
46
|
+
return self.client.sessions.get(self.id)
|
|
47
|
+
|
|
48
|
+
def update(self, body: dict[str, Any]):
|
|
49
|
+
response = self.client.sessions.update(self.id, body)
|
|
50
|
+
self.data = dict(response.get("session", self.data)) if isinstance(response, dict) else self.data
|
|
51
|
+
self.refresh_tools()
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
def set_provider_mutation_mode(
|
|
55
|
+
self,
|
|
56
|
+
mode: str,
|
|
57
|
+
*,
|
|
58
|
+
expected_config_version: int,
|
|
59
|
+
):
|
|
60
|
+
if mode not in {"read_only", "ask", "full_access"}:
|
|
61
|
+
raise ValueError("mode must be read_only, ask, or full_access")
|
|
62
|
+
return self.update({
|
|
63
|
+
"expectedConfigVersion": expected_config_version,
|
|
64
|
+
"providerMutationMode": mode,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
def close(self):
|
|
68
|
+
return self.client.sessions.close(self.id)
|
|
69
|
+
|
|
70
|
+
def fail(self, body: dict[str, Any] | None = None):
|
|
71
|
+
return self.client.sessions.fail(self.id, body)
|
|
72
|
+
|
|
73
|
+
def trace(self, **query):
|
|
74
|
+
return self.client.sessions.trace(self.id, **query)
|
|
75
|
+
|
|
76
|
+
def config_history(self, **query):
|
|
77
|
+
return self.client.sessions.config_history(self.id, **query)
|
|
78
|
+
|
|
79
|
+
def refresh_tools(self) -> dict[str, Any]:
|
|
80
|
+
self._tool_projection = self.client.sessions.meta_tools(self.id)
|
|
81
|
+
return self._tool_projection
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def tool_contract_version(self) -> str:
|
|
85
|
+
self._ensure_tools()
|
|
86
|
+
return str(self._tool_projection.get("contractVersion", "unknown"))
|
|
87
|
+
|
|
88
|
+
def tool_registry(self) -> list[SessionToolDefinition]:
|
|
89
|
+
self._ensure_tools()
|
|
90
|
+
tools = self._tool_projection.get("tools", [])
|
|
91
|
+
return [dict(tool) for tool in tools if isinstance(tool, dict) and isinstance(tool.get("name"), str)]
|
|
92
|
+
|
|
93
|
+
def tools(self) -> dict[str, SessionTool]:
|
|
94
|
+
return {definition["name"]: SessionTool(self, definition) for definition in self.tool_registry()}
|
|
95
|
+
|
|
96
|
+
def mcp_connection(self) -> dict[str, Any]:
|
|
97
|
+
descriptor = self.data.get("mcp")
|
|
98
|
+
if not isinstance(descriptor, dict) or not isinstance(descriptor.get("url"), str):
|
|
99
|
+
response = self.get()
|
|
100
|
+
session_data = response.get("session", {}) if isinstance(response, dict) else {}
|
|
101
|
+
self.data = dict(session_data)
|
|
102
|
+
descriptor = self.data.get("mcp")
|
|
103
|
+
if not isinstance(descriptor, dict) or not isinstance(descriptor.get("url"), str):
|
|
104
|
+
raise ValueError("This Stacklink session does not include an MCP connection descriptor.")
|
|
105
|
+
headers = descriptor.get("headers")
|
|
106
|
+
return {
|
|
107
|
+
"url": descriptor["url"],
|
|
108
|
+
"headers": {
|
|
109
|
+
**(headers if isinstance(headers, dict) else {}),
|
|
110
|
+
"Authorization": f"Bearer {self.client.api_key}",
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def langchain_tools(self) -> list[dict[str, Any]]:
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
"name": tool.name,
|
|
118
|
+
"description": tool.description,
|
|
119
|
+
"schema": tool.parameters,
|
|
120
|
+
"invoke": tool.call,
|
|
121
|
+
"func": tool.call,
|
|
122
|
+
}
|
|
123
|
+
for tool in self.tools().values()
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def _ensure_tools(self) -> None:
|
|
127
|
+
if not isinstance(self._tool_projection.get("tools"), list):
|
|
128
|
+
self.refresh_tools()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SessionsResource:
|
|
132
|
+
def __init__(self, client):
|
|
133
|
+
self.client = client
|
|
134
|
+
|
|
135
|
+
def create(self, body: dict[str, Any]):
|
|
136
|
+
return self.client.request("POST", "/v1/sessions", json_body=body)
|
|
137
|
+
|
|
138
|
+
def create_bound(self, body: dict[str, Any]) -> Session:
|
|
139
|
+
response = self.create(body)
|
|
140
|
+
session_data = response.get("session", {}) if isinstance(response, dict) else {}
|
|
141
|
+
session_id = str(session_data["id"])
|
|
142
|
+
return Session(
|
|
143
|
+
self.client,
|
|
144
|
+
session_id,
|
|
145
|
+
session_data=session_data,
|
|
146
|
+
tool_projection=self.meta_tools(session_id),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def bind(self, session_id: str) -> Session:
|
|
150
|
+
return Session(self.client, session_id)
|
|
151
|
+
|
|
152
|
+
def use(self, session_id: str) -> Session:
|
|
153
|
+
response = self.get(session_id)
|
|
154
|
+
session_data = response.get("session", {}) if isinstance(response, dict) else {}
|
|
155
|
+
return Session(
|
|
156
|
+
self.client,
|
|
157
|
+
session_id,
|
|
158
|
+
session_data=session_data,
|
|
159
|
+
tool_projection=self.meta_tools(session_id),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def list(self, **query):
|
|
163
|
+
return self.client.request("GET", "/v1/sessions", query=query)
|
|
164
|
+
|
|
165
|
+
def get(self, session_id: str):
|
|
166
|
+
return self.client.request("GET", f"/v1/sessions/{session_id}")
|
|
167
|
+
|
|
168
|
+
def update(self, session_id: str, body: dict[str, Any]):
|
|
169
|
+
response = self.client.request("PATCH", f"/v1/sessions/{session_id}", json_body=body)
|
|
170
|
+
self.client.clear_cache(f"session:{session_id}:")
|
|
171
|
+
return response
|
|
172
|
+
|
|
173
|
+
def meta_tools(self, session_id: str):
|
|
174
|
+
return self.client.request("GET", f"/v1/sessions/{session_id}/meta-tools")
|
|
175
|
+
|
|
176
|
+
def close(self, session_id: str):
|
|
177
|
+
return self.client.request("POST", f"/v1/sessions/{session_id}/close")
|
|
178
|
+
|
|
179
|
+
def fail(self, session_id: str, body: dict[str, Any] | None = None):
|
|
180
|
+
return self.client.request("POST", f"/v1/sessions/{session_id}/fail", json_body=body or {})
|
|
181
|
+
|
|
182
|
+
def router_facts(self, session_id: str, **query):
|
|
183
|
+
return self.client.request("GET", f"/v1/sessions/{session_id}/router-facts", query=query)
|
|
184
|
+
|
|
185
|
+
def workbench(self, session_id: str):
|
|
186
|
+
return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
|
|
187
|
+
|
|
188
|
+
def trace(self, session_id: str, **query):
|
|
189
|
+
return self.client.request("GET", f"/v1/sessions/{session_id}/trace", query=query)
|
|
190
|
+
|
|
191
|
+
def config_history(self, session_id: str, **query):
|
|
192
|
+
return self.client.request("GET", f"/v1/sessions/{session_id}/config-history", query=query)
|
|
193
|
+
|
|
194
|
+
def toolkits(self, session_id: str) -> dict[str, list[ProjectProvider]]:
|
|
195
|
+
return self.client.cached_request(
|
|
196
|
+
f"session:{session_id}:toolkits",
|
|
197
|
+
"GET",
|
|
198
|
+
f"/v1/sessions/{session_id}/toolkits",
|
|
199
|
+
ttl_seconds=300,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def search_tools(self, session_id: str, body: dict[str, Any], *, cache: str = "default"):
|
|
203
|
+
return self.client.tools.search_for_session(session_id, body, cache=cache)
|
|
204
|
+
|
|
205
|
+
def get_tool_schemas(self, session_id: str, body: dict[str, Any], *, cache: str = "default"):
|
|
206
|
+
return self.client.tools.schemas_for_session(session_id, body, cache=cache)
|
|
207
|
+
|
|
208
|
+
def prepare_tools(self, session_id: str, **kwargs):
|
|
209
|
+
return self.client.tools.prepare_for_session(session_id, **kwargs)
|
|
210
|
+
|
|
211
|
+
def prepare_runtime_tools(self, session_id: str, **kwargs):
|
|
212
|
+
return self.client.tools.prepare_runtime_for_session(session_id, **kwargs)
|
|
213
|
+
|
|
214
|
+
def stream_events(self, session_id: str, **query):
|
|
215
|
+
return self.client.request_sse("GET", f"/v1/sessions/{session_id}/events/stream", query=query)
|
|
216
|
+
|
|
217
|
+
def authorize_connection(self, session_id: str, body: dict[str, Any]):
|
|
218
|
+
return self.client.request("POST", f"/v1/sessions/{session_id}/connections/authorize", json_body=body)
|
|
219
|
+
|
|
220
|
+
def mcp_url(self, session: dict[str, Any] | Session) -> str | None:
|
|
221
|
+
value = session.data if isinstance(session, Session) else session
|
|
222
|
+
mcp = value.get("mcp")
|
|
223
|
+
return mcp.get("url") if isinstance(mcp, dict) else None
|