stacklink-sdk 0.3.7__tar.gz → 0.3.10__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.
- {stacklink_sdk-0.3.7/stacklink_sdk.egg-info → stacklink_sdk-0.3.10}/PKG-INFO +29 -1
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/README.md +26 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/pyproject.toml +2 -1
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/client.py +11 -8
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/errors.py +2 -1
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/executions.py +4 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/workbench.py +63 -31
- stacklink_sdk-0.3.10/stacklink/x_chat.py +138 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10/stacklink_sdk.egg-info}/PKG-INFO +29 -1
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink_sdk.egg-info/SOURCES.txt +1 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink_sdk.egg-info/requires.txt +3 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/tests/test_client.py +48 -1
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/LICENSE +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/setup.cfg +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/__init__.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/api_keys.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/approvals.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/artifacts.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/auth_configs.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/billing.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/bots.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/claude_agent.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/connected_accounts.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/knowledge.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/logs.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/mcp.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/memory.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/openai_agents.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/openai_memory.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/projects.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/providers.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/sessions.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/tools.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/triggers.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/types.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink/webhooks.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/stacklink_sdk.egg-info/top_level.txt +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/tests/test_google_docs_provider.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/tests/test_google_drive_provider.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/tests/test_google_sheets_provider.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/tests/test_openai_memory.py +0 -0
- {stacklink_sdk-0.3.7 → stacklink_sdk-0.3.10}/tests/test_zoho_books_provider.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stacklink-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.10
|
|
4
4
|
Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
|
|
5
5
|
Author: Stacklink
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -9,6 +9,8 @@ Requires-Python: >=3.10
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Dist: httpx[http2]<1,>=0.27
|
|
12
|
+
Provides-Extra: x-chat
|
|
13
|
+
Requires-Dist: chatxdk==0.5.0; extra == "x-chat"
|
|
12
14
|
Provides-Extra: openai
|
|
13
15
|
Requires-Dist: openai<3,>=2.0; extra == "openai"
|
|
14
16
|
Provides-Extra: openai-agents
|
|
@@ -19,6 +21,32 @@ Dynamic: license-file
|
|
|
19
21
|
|
|
20
22
|
# Stacklink Python SDK
|
|
21
23
|
|
|
24
|
+
## X Chat bot keys
|
|
25
|
+
|
|
26
|
+
Install `stacklink-sdk[x-chat]` for the official native Chat XDK. Import
|
|
27
|
+
`create_x_bot_chat`, `restore_x_bot_chat` and `XBotChatKeyState` from
|
|
28
|
+
`stacklink.x_chat`. This optional module runs on your bot infrastructure and makes
|
|
29
|
+
no network requests. Personal X Chat remains in the browser SDK; do not pass a
|
|
30
|
+
person's PIN or private keys to this module or to Stacklink's backend.
|
|
31
|
+
|
|
32
|
+
Call `create_x_bot_chat(account_id, bot_user_id, persist)` once. The `persist`
|
|
33
|
+
callback must store the opaque key state in your secret manager, replacing the
|
|
34
|
+
value for that exact account and bot before returning. Submit the returned
|
|
35
|
+
`registration` public-key payload through X's bot bearer-token API. Fetch the
|
|
36
|
+
registered public-key record, add its exact `user_id`, and call
|
|
37
|
+
`bind_registered_identity(record)`. The helper checks both public keys and their
|
|
38
|
+
binding before exposing `crypto`, the official native XDK for messages, groups,
|
|
39
|
+
reactions, history and encrypted media. Close the session after use.
|
|
40
|
+
|
|
41
|
+
On restart, call `restore_x_bot_chat(account_id, bot_user_id, stored_state,
|
|
42
|
+
current_public_record, persist)`. Account, bot, key version and both keys must
|
|
43
|
+
match. An uncertain registration must be reconciled using X's current public
|
|
44
|
+
record; do not generate replacement keys automatically. Keep the bot bearer
|
|
45
|
+
token retrieved by `x.getStoredBotToken` in the same customer-owned secret
|
|
46
|
+
manager, separately from its private key blob. Bot bearer tokens are separate
|
|
47
|
+
from a person's OAuth2 grant. This helper supplies local cryptography; it does
|
|
48
|
+
not turn a person's Stacklink connection into a bot connection.
|
|
49
|
+
|
|
22
50
|
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
23
51
|
|
|
24
52
|
Install the public package with the harness extra you use:
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Stacklink Python SDK
|
|
2
2
|
|
|
3
|
+
## X Chat bot keys
|
|
4
|
+
|
|
5
|
+
Install `stacklink-sdk[x-chat]` for the official native Chat XDK. Import
|
|
6
|
+
`create_x_bot_chat`, `restore_x_bot_chat` and `XBotChatKeyState` from
|
|
7
|
+
`stacklink.x_chat`. This optional module runs on your bot infrastructure and makes
|
|
8
|
+
no network requests. Personal X Chat remains in the browser SDK; do not pass a
|
|
9
|
+
person's PIN or private keys to this module or to Stacklink's backend.
|
|
10
|
+
|
|
11
|
+
Call `create_x_bot_chat(account_id, bot_user_id, persist)` once. The `persist`
|
|
12
|
+
callback must store the opaque key state in your secret manager, replacing the
|
|
13
|
+
value for that exact account and bot before returning. Submit the returned
|
|
14
|
+
`registration` public-key payload through X's bot bearer-token API. Fetch the
|
|
15
|
+
registered public-key record, add its exact `user_id`, and call
|
|
16
|
+
`bind_registered_identity(record)`. The helper checks both public keys and their
|
|
17
|
+
binding before exposing `crypto`, the official native XDK for messages, groups,
|
|
18
|
+
reactions, history and encrypted media. Close the session after use.
|
|
19
|
+
|
|
20
|
+
On restart, call `restore_x_bot_chat(account_id, bot_user_id, stored_state,
|
|
21
|
+
current_public_record, persist)`. Account, bot, key version and both keys must
|
|
22
|
+
match. An uncertain registration must be reconciled using X's current public
|
|
23
|
+
record; do not generate replacement keys automatically. Keep the bot bearer
|
|
24
|
+
token retrieved by `x.getStoredBotToken` in the same customer-owned secret
|
|
25
|
+
manager, separately from its private key blob. Bot bearer tokens are separate
|
|
26
|
+
from a person's OAuth2 grant. This helper supplies local cryptography; it does
|
|
27
|
+
not turn a person's Stacklink connection into a bot connection.
|
|
28
|
+
|
|
3
29
|
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
4
30
|
|
|
5
31
|
Install the public package with the harness extra you use:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stacklink-sdk"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.10"
|
|
8
8
|
description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LicenseRef-Proprietary"
|
|
@@ -17,6 +17,7 @@ dependencies = ["httpx[http2]>=0.27,<1"]
|
|
|
17
17
|
Documentation = "https://docs.stacklink.in/docs/quickstart"
|
|
18
18
|
|
|
19
19
|
[project.optional-dependencies]
|
|
20
|
+
x-chat = ["chatxdk==0.5.0"]
|
|
20
21
|
openai = ["openai>=2.0,<3"]
|
|
21
22
|
openai-agents = ["openai-agents>=0.18,<0.19"]
|
|
22
23
|
claude-agent = ["claude-agent-sdk>=0.2,<0.3"]
|
|
@@ -37,7 +37,7 @@ from .types import Json, Transport
|
|
|
37
37
|
from .webhooks import WebhooksResource
|
|
38
38
|
from .workbench import WorkbenchResource
|
|
39
39
|
|
|
40
|
-
STACKLINK_PYTHON_SDK_VERSION = "0.3.
|
|
40
|
+
STACKLINK_PYTHON_SDK_VERSION = "0.3.10"
|
|
41
41
|
STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
|
|
42
42
|
|
|
43
43
|
|
|
@@ -191,6 +191,7 @@ class Stacklink:
|
|
|
191
191
|
headers: Mapping[str, str] | None = None,
|
|
192
192
|
total_timeout_ms: int | None = None,
|
|
193
193
|
idle_timeout_ms: int = 30_000,
|
|
194
|
+
cancel_event: threading.Event | None = None,
|
|
194
195
|
) -> Iterator[dict[str, Any]]:
|
|
195
196
|
timeout_seconds = _positive_timeout_seconds(total_timeout_ms or int(self.timeout * 1000))
|
|
196
197
|
idle_timeout_seconds = _positive_timeout_seconds(idle_timeout_ms)
|
|
@@ -263,7 +264,7 @@ class Stacklink:
|
|
|
263
264
|
raise _stream_timeout_error() from error
|
|
264
265
|
raise StacklinkError("Stacklink stream stopped receiving data.", status_code=408, code="sdk_stream_idle_timeout") from error
|
|
265
266
|
|
|
266
|
-
yield from _observe_sync_stream(receive, deadline)
|
|
267
|
+
yield from _observe_sync_stream(receive, deadline, cancel_event)
|
|
267
268
|
|
|
268
269
|
def _perform_request(
|
|
269
270
|
self,
|
|
@@ -366,7 +367,7 @@ class Stacklink:
|
|
|
366
367
|
details = body.get("details") if isinstance(body, dict) else None
|
|
367
368
|
if status == 429:
|
|
368
369
|
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
369
|
-
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details)
|
|
370
|
+
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
|
|
370
371
|
|
|
371
372
|
def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
|
|
372
373
|
event_id = None
|
|
@@ -776,7 +777,7 @@ class _StreamLines:
|
|
|
776
777
|
return lines
|
|
777
778
|
|
|
778
779
|
|
|
779
|
-
def _observe_sync_stream(source: Callable[[threading.Event], Iterator[_StreamValue]], deadline: float) -> Iterator[_StreamValue]:
|
|
780
|
+
def _observe_sync_stream(source: Callable[[threading.Event], Iterator[_StreamValue]], deadline: float, cancel_event: threading.Event | None = None) -> Iterator[_StreamValue]:
|
|
780
781
|
# A blocking httpx read has an inactivity timeout, not a total deadline.
|
|
781
782
|
# Keep it in one reader with bounded backpressure so the caller's observation
|
|
782
783
|
# can end on time. The reader owns and closes the response; it never retries.
|
|
@@ -810,13 +811,15 @@ def _observe_sync_stream(source: Callable[[threading.Event], Iterator[_StreamVal
|
|
|
810
811
|
threading.Thread(target=context.run, args=(read,), daemon=True, name="stacklink-stream-reader").start()
|
|
811
812
|
try:
|
|
812
813
|
while True:
|
|
814
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
815
|
+
raise StacklinkError("Execution observation was cancelled.", code="sdk_stream_cancelled")
|
|
813
816
|
remaining = deadline - time.monotonic()
|
|
814
817
|
if remaining <= 0:
|
|
815
818
|
raise _stream_timeout_error()
|
|
816
819
|
try:
|
|
817
|
-
kind, value = messages.get(timeout=remaining)
|
|
818
|
-
except queue.Empty
|
|
819
|
-
|
|
820
|
+
kind, value = messages.get(timeout=min(remaining, 0.05) if cancel_event is not None else remaining)
|
|
821
|
+
except queue.Empty:
|
|
822
|
+
continue
|
|
820
823
|
if kind == "done":
|
|
821
824
|
return
|
|
822
825
|
if kind == "error":
|
|
@@ -873,7 +876,7 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
|
|
|
873
876
|
details = body.get("details") if isinstance(body, dict) else None
|
|
874
877
|
if status == 429:
|
|
875
878
|
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
876
|
-
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details)
|
|
879
|
+
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
|
|
877
880
|
|
|
878
881
|
|
|
879
882
|
def _timeout_error(timeout_seconds: float, request_id: str) -> StacklinkError:
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
class StacklinkError(Exception):
|
|
4
|
-
def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None):
|
|
4
|
+
def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None):
|
|
5
5
|
super().__init__(message)
|
|
6
6
|
self.status_code = status_code
|
|
7
7
|
self.code = code
|
|
8
8
|
self.details = details
|
|
9
|
+
self.headers = headers or {}
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class RateLimitError(StacklinkError):
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import threading
|
|
4
|
+
|
|
3
5
|
from typing import Any, TypedDict, cast
|
|
4
6
|
|
|
5
7
|
|
|
@@ -47,6 +49,7 @@ class ExecutionsResource:
|
|
|
47
49
|
after_sequence: str | None = None,
|
|
48
50
|
limit: int = 200,
|
|
49
51
|
total_timeout_ms: int = 30_000,
|
|
52
|
+
cancel_event: threading.Event | None = None,
|
|
50
53
|
):
|
|
51
54
|
return self.client.request_sse(
|
|
52
55
|
"GET",
|
|
@@ -58,6 +61,7 @@ class ExecutionsResource:
|
|
|
58
61
|
},
|
|
59
62
|
headers={"Last-Event-ID": after_sequence} if after_sequence else None,
|
|
60
63
|
total_timeout_ms=total_timeout_ms,
|
|
64
|
+
cancel_event=cancel_event,
|
|
61
65
|
)
|
|
62
66
|
|
|
63
67
|
def wait(self, session_id: str, execution_kind: str, execution_id: str, body: dict[str, Any] | None = None):
|
|
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import time
|
|
5
|
+
import threading
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
import httpx
|
|
5
8
|
from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
|
|
6
9
|
from urllib.error import HTTPError
|
|
7
10
|
from urllib.request import Request, urlopen
|
|
@@ -482,41 +485,70 @@ class WorkbenchRun:
|
|
|
482
485
|
self.execution_id = str(response["executionId"])
|
|
483
486
|
self.execution_kind = str(response["executionKind"])
|
|
484
487
|
|
|
485
|
-
def events(self, *, stream_timeout_ms: int = 30_000, limit: int = 200
|
|
486
|
-
|
|
487
|
-
|
|
488
|
+
def events(self, *, stream_timeout_ms: int = 30_000, limit: int = 200,
|
|
489
|
+
total_timeout_ms: int = 3_600_000, max_reconnect_attempts: int = 8,
|
|
490
|
+
after_sequence: str | None = None, cancel_event: threading.Event | None = None) -> Iterator[WorkbenchExecutionEvent]:
|
|
491
|
+
if min(stream_timeout_ms, total_timeout_ms, max_reconnect_attempts) <= 0:
|
|
492
|
+
raise ValueError("Stream timeouts and reconnect attempts must be positive.")
|
|
493
|
+
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
494
|
+
cancelled = cancel_event or threading.Event()
|
|
495
|
+
failures = 0
|
|
496
|
+
while not cancelled.is_set() and time.monotonic() < deadline:
|
|
497
|
+
delay = min(1.0, 0.1 * 2 ** min(failures, 4))
|
|
488
498
|
try:
|
|
489
499
|
stream = self.client.executions.stream(
|
|
490
|
-
self.platform_session_id,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
after_sequence=after_sequence,
|
|
494
|
-
limit=limit,
|
|
495
|
-
total_timeout_ms=stream_timeout_ms,
|
|
500
|
+
self.platform_session_id, self.execution_kind, self.execution_id,
|
|
501
|
+
after_sequence=after_sequence, limit=limit, cancel_event=cancelled,
|
|
502
|
+
total_timeout_ms=min(stream_timeout_ms, max(1, int((deadline - time.monotonic()) * 1000))),
|
|
496
503
|
)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
504
|
+
try:
|
|
505
|
+
for message in stream:
|
|
506
|
+
if cancelled.is_set():
|
|
507
|
+
break
|
|
508
|
+
payload = message.get("data") if isinstance(message, dict) else None
|
|
509
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("events"), list):
|
|
510
|
+
raise StacklinkError("Execution stream returned an invalid event page.", code="execution_event_invalid")
|
|
511
|
+
failures = 0
|
|
512
|
+
for event in payload["events"]:
|
|
513
|
+
if not isinstance(event, dict) or not isinstance(event.get("sequence"), (str, int)):
|
|
514
|
+
raise StacklinkError("Execution event has no durable sequence.", code="execution_event_invalid")
|
|
515
|
+
sequence = str(event["sequence"])
|
|
516
|
+
if after_sequence is not None and int(sequence) <= int(after_sequence):
|
|
505
517
|
continue
|
|
506
|
-
|
|
507
|
-
yield event
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
518
|
+
after_sequence = sequence
|
|
519
|
+
yield cast(WorkbenchExecutionEvent, event)
|
|
520
|
+
next_cursor = payload.get("nextCursor")
|
|
521
|
+
if isinstance(next_cursor, str) and (after_sequence is None or int(next_cursor) > int(after_sequence)):
|
|
522
|
+
after_sequence = next_cursor
|
|
523
|
+
if payload.get("terminal") is True:
|
|
524
|
+
return
|
|
525
|
+
finally:
|
|
526
|
+
close = getattr(stream, "close", None)
|
|
527
|
+
if close:
|
|
528
|
+
close()
|
|
529
|
+
except (StacklinkError, httpx.TransportError) as error:
|
|
530
|
+
if isinstance(error, StacklinkError):
|
|
531
|
+
if error.code not in {"sdk_stream_timeout", "sdk_stream_idle_timeout"} and error.status_code not in {408, 429, 502, 503, 504}:
|
|
532
|
+
raise
|
|
533
|
+
headers = getattr(error, "headers", {})
|
|
534
|
+
retry_after = next((value for key, value in headers.items() if key.lower() == "retry-after"), None)
|
|
535
|
+
if retry_after is not None:
|
|
536
|
+
try:
|
|
537
|
+
delay = max(0.0, float(retry_after))
|
|
538
|
+
except ValueError:
|
|
539
|
+
try:
|
|
540
|
+
delay = max(0.0, parsedate_to_datetime(retry_after).timestamp() - time.time())
|
|
541
|
+
except (ValueError, TypeError, OverflowError):
|
|
542
|
+
pass
|
|
543
|
+
failures += 1
|
|
544
|
+
if failures > max_reconnect_attempts:
|
|
545
|
+
raise StacklinkError("Execution observation could not reconnect. Resume the same execution.", code="sdk_stream_reconnect_exhausted", details={"executionId": self.execution_id, "afterSequence": after_sequence})
|
|
546
|
+
if delay >= deadline - time.monotonic():
|
|
547
|
+
break
|
|
548
|
+
cancelled.wait(delay)
|
|
549
|
+
raise StacklinkError("Execution observation stopped; the operation was not resubmitted or cancelled.",
|
|
550
|
+
code="sdk_stream_cancelled" if cancelled.is_set() else "sdk_stream_observation_timeout",
|
|
551
|
+
details={"executionId": self.execution_id, "afterSequence": after_sequence})
|
|
520
552
|
|
|
521
553
|
def wait(self, *, wait_timeout_ms: int | None = None) -> WorkbenchExecutionStateResponse:
|
|
522
554
|
body = {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Optional native X Chat for bot-owned keys on the customer's infrastructure.
|
|
2
|
+
|
|
3
|
+
This module performs no HTTP requests. It never accepts a personal Chat PIN.
|
|
4
|
+
Persist key state in your secret manager, never in tool inputs or execution logs.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Callable, Mapping, Any
|
|
10
|
+
|
|
11
|
+
from chat_xdk import Chat
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class XBotChatKeyState:
|
|
16
|
+
account_id: str
|
|
17
|
+
bot_user_id: str
|
|
18
|
+
key_version: str
|
|
19
|
+
key_blob: bytes = field(repr=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class XBotChat:
|
|
23
|
+
"""Binds the official native crypto engine to one bot and registered key.
|
|
24
|
+
|
|
25
|
+
``persist`` must replace the secret for the exact (account_id, bot_user_id)
|
|
26
|
+
pair before returning. The registered version is part of its stored value.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, account_id: str, bot_user_id: str, chat: Chat,
|
|
30
|
+
persist: Callable[[XBotChatKeyState], None], key_version: str,
|
|
31
|
+
registration: dict[str, Any] | None = None):
|
|
32
|
+
if not account_id or not bot_user_id or not key_version:
|
|
33
|
+
raise ValueError("Bind bot keys to an exact account, X user and key version.")
|
|
34
|
+
self.account_id = account_id
|
|
35
|
+
self.bot_user_id = bot_user_id
|
|
36
|
+
self.registration = registration
|
|
37
|
+
self._chat = chat
|
|
38
|
+
self._persist = persist
|
|
39
|
+
self._key_version = key_version
|
|
40
|
+
self._bound = False
|
|
41
|
+
self._closed = False
|
|
42
|
+
chat.set_reject_unverified(True)
|
|
43
|
+
chat.set_cache_keys(True)
|
|
44
|
+
|
|
45
|
+
def _save(self) -> None:
|
|
46
|
+
blob = self._chat.export_keys()
|
|
47
|
+
try:
|
|
48
|
+
self._persist(XBotChatKeyState(self.account_id, self.bot_user_id,
|
|
49
|
+
self._key_version, bytes(blob)))
|
|
50
|
+
finally:
|
|
51
|
+
blob[:] = bytes(len(blob))
|
|
52
|
+
|
|
53
|
+
def bind_registered_identity(self, record: Mapping[str, str]) -> None:
|
|
54
|
+
"""Bind a fresh public-key record returned by X for this exact bot.
|
|
55
|
+
|
|
56
|
+
The identity and signing keys must both match the locally held keys.
|
|
57
|
+
Only public metadata is passed here; secure-backup tokens are not needed.
|
|
58
|
+
"""
|
|
59
|
+
if self._closed:
|
|
60
|
+
raise RuntimeError("This X bot Chat session is closed.")
|
|
61
|
+
self._bound = False
|
|
62
|
+
if record.get("user_id") != self.bot_user_id:
|
|
63
|
+
raise ValueError("The X public-key record belongs to a different bot.")
|
|
64
|
+
version = record.get("public_key_version")
|
|
65
|
+
if not version:
|
|
66
|
+
raise ValueError("X must return the registered public-key version.")
|
|
67
|
+
challenge = b"stacklink.x_bot_chat.registered_signing_key"
|
|
68
|
+
if not self._chat.matches_registered_key(record.get("public_key", "")) or not self._chat.verify(
|
|
69
|
+
record.get("signing_public_key", ""), self._chat.sign(challenge), challenge):
|
|
70
|
+
raise ValueError("The X public keys do not match this bot's stored keys.")
|
|
71
|
+
if not self._chat.verify_key_binding(record["public_key"], record["signing_public_key"],
|
|
72
|
+
record["identity_public_key_signature"]):
|
|
73
|
+
raise ValueError("The X public-key binding is invalid.")
|
|
74
|
+
self._key_version = version
|
|
75
|
+
self._chat.set_identity(self.bot_user_id, version)
|
|
76
|
+
self._save()
|
|
77
|
+
self._bound = True
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def crypto(self) -> Chat:
|
|
81
|
+
"""The official Chat XDK, including messages, groups, events and media."""
|
|
82
|
+
if self._closed or not self._bound:
|
|
83
|
+
raise RuntimeError("Bind the registered bot identity before using Chat.")
|
|
84
|
+
return self._chat
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
self._closed = True
|
|
88
|
+
self._bound = False
|
|
89
|
+
self._chat.lock()
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> XBotChat:
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *_: object) -> None:
|
|
95
|
+
self.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_x_bot_chat(account_id: str, bot_user_id: str,
|
|
99
|
+
persist: Callable[[XBotChatKeyState], None]) -> XBotChat:
|
|
100
|
+
"""Generate and persist keys before submitting ``registration`` to X.
|
|
101
|
+
|
|
102
|
+
After registration, fetch the bot's public-key record and call
|
|
103
|
+
``bind_registered_identity``. An uncertain registration must be reconciled
|
|
104
|
+
against that record; do not generate a second set of keys automatically.
|
|
105
|
+
"""
|
|
106
|
+
chat = Chat()
|
|
107
|
+
try:
|
|
108
|
+
registration = chat.generate_keypairs()
|
|
109
|
+
key = registration.public_key
|
|
110
|
+
body = {"version": registration.version, "generate_version": registration.generate_version,
|
|
111
|
+
"public_key": {name: getattr(key, name) for name in (
|
|
112
|
+
"identity_public_key_signature", "public_key", "public_key_fingerprint",
|
|
113
|
+
"registration_method", "signing_public_key", "signing_public_key_signature")}}
|
|
114
|
+
session = XBotChat(account_id, bot_user_id, chat, persist, str(registration.version), body)
|
|
115
|
+
session._save()
|
|
116
|
+
return session
|
|
117
|
+
except BaseException:
|
|
118
|
+
chat.lock()
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def restore_x_bot_chat(account_id: str, bot_user_id: str, state: XBotChatKeyState,
|
|
123
|
+
record: Mapping[str, str],
|
|
124
|
+
persist: Callable[[XBotChatKeyState], None]) -> XBotChat:
|
|
125
|
+
"""Load an exact bot key blob and verify it against the current X record."""
|
|
126
|
+
if state.account_id != account_id or state.bot_user_id != bot_user_id:
|
|
127
|
+
raise ValueError("Stored Chat keys belong to a different account or bot.")
|
|
128
|
+
if state.key_version != record.get("public_key_version"):
|
|
129
|
+
raise ValueError("The registered bot key version has changed.")
|
|
130
|
+
chat = Chat()
|
|
131
|
+
try:
|
|
132
|
+
chat.import_keys(state.key_blob, version=state.key_version)
|
|
133
|
+
session = XBotChat(account_id, bot_user_id, chat, persist, state.key_version)
|
|
134
|
+
session.bind_registered_identity(record)
|
|
135
|
+
return session
|
|
136
|
+
except BaseException:
|
|
137
|
+
chat.lock()
|
|
138
|
+
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stacklink-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.10
|
|
4
4
|
Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
|
|
5
5
|
Author: Stacklink
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -9,6 +9,8 @@ Requires-Python: >=3.10
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Dist: httpx[http2]<1,>=0.27
|
|
12
|
+
Provides-Extra: x-chat
|
|
13
|
+
Requires-Dist: chatxdk==0.5.0; extra == "x-chat"
|
|
12
14
|
Provides-Extra: openai
|
|
13
15
|
Requires-Dist: openai<3,>=2.0; extra == "openai"
|
|
14
16
|
Provides-Extra: openai-agents
|
|
@@ -19,6 +21,32 @@ Dynamic: license-file
|
|
|
19
21
|
|
|
20
22
|
# Stacklink Python SDK
|
|
21
23
|
|
|
24
|
+
## X Chat bot keys
|
|
25
|
+
|
|
26
|
+
Install `stacklink-sdk[x-chat]` for the official native Chat XDK. Import
|
|
27
|
+
`create_x_bot_chat`, `restore_x_bot_chat` and `XBotChatKeyState` from
|
|
28
|
+
`stacklink.x_chat`. This optional module runs on your bot infrastructure and makes
|
|
29
|
+
no network requests. Personal X Chat remains in the browser SDK; do not pass a
|
|
30
|
+
person's PIN or private keys to this module or to Stacklink's backend.
|
|
31
|
+
|
|
32
|
+
Call `create_x_bot_chat(account_id, bot_user_id, persist)` once. The `persist`
|
|
33
|
+
callback must store the opaque key state in your secret manager, replacing the
|
|
34
|
+
value for that exact account and bot before returning. Submit the returned
|
|
35
|
+
`registration` public-key payload through X's bot bearer-token API. Fetch the
|
|
36
|
+
registered public-key record, add its exact `user_id`, and call
|
|
37
|
+
`bind_registered_identity(record)`. The helper checks both public keys and their
|
|
38
|
+
binding before exposing `crypto`, the official native XDK for messages, groups,
|
|
39
|
+
reactions, history and encrypted media. Close the session after use.
|
|
40
|
+
|
|
41
|
+
On restart, call `restore_x_bot_chat(account_id, bot_user_id, stored_state,
|
|
42
|
+
current_public_record, persist)`. Account, bot, key version and both keys must
|
|
43
|
+
match. An uncertain registration must be reconciled using X's current public
|
|
44
|
+
record; do not generate replacement keys automatically. Keep the bot bearer
|
|
45
|
+
token retrieved by `x.getStoredBotToken` in the same customer-owned secret
|
|
46
|
+
manager, separately from its private key blob. Bot bearer tokens are separate
|
|
47
|
+
from a person's OAuth2 grant. This helper supplies local cryptography; it does
|
|
48
|
+
not turn a person's Stacklink connection into a bot connection.
|
|
49
|
+
|
|
22
50
|
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
23
51
|
|
|
24
52
|
Install the public package with the harness extra you use:
|
|
@@ -24,7 +24,7 @@ from stacklink.workbench import WorkbenchRun
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
@contextmanager
|
|
27
|
-
def stalled_stream_server(*, final_result=False):
|
|
27
|
+
def stalled_stream_server(*, final_result=False, execution_page=False):
|
|
28
28
|
"""A real HTTP stream that stays open after progress or a complete response."""
|
|
29
29
|
stop = threading.Event()
|
|
30
30
|
requests = []
|
|
@@ -43,6 +43,8 @@ def stalled_stream_server(*, final_result=False):
|
|
|
43
43
|
self.send_header("Content-Type", "text/event-stream")
|
|
44
44
|
self.end_headers()
|
|
45
45
|
message = {"jsonrpc": "2.0", "id": 7, "result": {"name": "नमस्ते"}} if final_result else {"progressToken": 0, "progress": 1}
|
|
46
|
+
if execution_page:
|
|
47
|
+
message = {"events": [{"sequence": "1"}], "terminal": False}
|
|
46
48
|
self.wfile.write(("data: " + json.dumps(message, ensure_ascii=False) + "\r\n\r\n").encode("utf-8"))
|
|
47
49
|
self.wfile.flush()
|
|
48
50
|
stop.wait(2)
|
|
@@ -1046,6 +1048,51 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
1046
1048
|
{"query": "1", "header": "1"},
|
|
1047
1049
|
])
|
|
1048
1050
|
|
|
1051
|
+
def test_workbench_observation_recovers_open_body_idle_and_rate_limit(self):
|
|
1052
|
+
for failure in ["open", "body", "idle", "rate"]:
|
|
1053
|
+
with self.subTest(failure=failure):
|
|
1054
|
+
calls = []
|
|
1055
|
+
def stream(*args, **kwargs):
|
|
1056
|
+
calls.append((args, kwargs))
|
|
1057
|
+
if len(calls) == 1:
|
|
1058
|
+
if failure == "open":
|
|
1059
|
+
raise httpx.ConnectError("offline")
|
|
1060
|
+
if failure == "rate":
|
|
1061
|
+
raise StacklinkError("busy", status_code=429, headers={"Retry-After": "0"})
|
|
1062
|
+
yield {"data": {"events": [{"sequence": "1"}], "terminal": False}}
|
|
1063
|
+
if failure == "idle":
|
|
1064
|
+
raise StacklinkError("idle", code="sdk_stream_idle_timeout")
|
|
1065
|
+
raise httpx.ReadError("interrupted")
|
|
1066
|
+
yield {"data": {"events": [{"sequence": "1"}, {"sequence": "2", "eventType": "completed"}], "terminal": True}}
|
|
1067
|
+
run = WorkbenchRun(SimpleNamespace(executions=SimpleNamespace(stream=stream)), "session", {"executionId": "same-execution", "executionKind": "workbench_bash"})
|
|
1068
|
+
self.assertEqual([e["sequence"] for e in run.events()], ["1", "2"])
|
|
1069
|
+
self.assertEqual(len(calls), 2)
|
|
1070
|
+
self.assertTrue(all(call[0][2] == "same-execution" for call in calls))
|
|
1071
|
+
self.assertEqual(calls[1][1]["after_sequence"], "1" if failure in {"body", "idle"} else None)
|
|
1072
|
+
|
|
1073
|
+
def test_workbench_observation_honours_retry_after_deadline_and_cancellation(self):
|
|
1074
|
+
def unavailable(*args, **kwargs):
|
|
1075
|
+
raise StacklinkError("busy", status_code=503, headers={"Retry-After": "3600"})
|
|
1076
|
+
run = WorkbenchRun(SimpleNamespace(executions=SimpleNamespace(stream=unavailable)), "session", {"executionId": "same-execution", "executionKind": "workbench_bash"})
|
|
1077
|
+
with self.assertRaises(StacklinkError) as raised:
|
|
1078
|
+
list(run.events(total_timeout_ms=100))
|
|
1079
|
+
self.assertEqual(raised.exception.code, "sdk_stream_observation_timeout")
|
|
1080
|
+
with stalled_stream_server(execution_page=True) as (url, _requests):
|
|
1081
|
+
client = Stacklink(api_key="ppk_test", base_url=url)
|
|
1082
|
+
run = WorkbenchRun(client, "session", {"executionId": "same-execution", "executionKind": "workbench_bash"})
|
|
1083
|
+
cancelled = threading.Event()
|
|
1084
|
+
timer = threading.Timer(0.1, cancelled.set)
|
|
1085
|
+
timer.start()
|
|
1086
|
+
started = time.monotonic()
|
|
1087
|
+
try:
|
|
1088
|
+
with self.assertRaises(StacklinkError) as raised:
|
|
1089
|
+
list(run.events(cancel_event=cancelled))
|
|
1090
|
+
self.assertEqual(raised.exception.code, "sdk_stream_cancelled")
|
|
1091
|
+
self.assertLess(time.monotonic() - started, 1)
|
|
1092
|
+
finally:
|
|
1093
|
+
timer.cancel()
|
|
1094
|
+
client.close()
|
|
1095
|
+
|
|
1049
1096
|
def test_bound_workbench_exposes_reusable_run_lifecycle(self):
|
|
1050
1097
|
calls = []
|
|
1051
1098
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|