stacklink-sdk 0.3.19__tar.gz → 0.4.0__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.19/stacklink_sdk.egg-info → stacklink_sdk-0.4.0}/PKG-INFO +1 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/pyproject.toml +1 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/bots.py +23 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/client.py +8 -11
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/errors.py +4 -3
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/mcp.py +4 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/workbench.py +109 -36
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0/stacklink_sdk.egg-info}/PKG-INFO +1 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_client.py +68 -2
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/LICENSE +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/README.md +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/setup.cfg +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/__init__.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/api_keys.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/approvals.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/artifacts.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/auth_configs.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/billing.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/claude_agent.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/connected_accounts.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/executions.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/knowledge.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/logs.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/memory.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/openai_agents.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/openai_memory.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/projects.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/providers.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/sessions.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/tools.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/triggers.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/types.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/webhooks.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/x_chat.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/SOURCES.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/requires.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/top_level.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_google_docs_provider.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_google_drive_provider.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_google_sheets_provider.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_openai_memory.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_zoho_books_provider.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stacklink-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
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"
|
|
@@ -53,6 +53,12 @@ class BotExecutionWait(TypedDict):
|
|
|
53
53
|
kind: Literal["execution"]
|
|
54
54
|
id: str
|
|
55
55
|
|
|
56
|
+
class BotAttachmentPreparationWait(TypedDict):
|
|
57
|
+
kind: Literal["attachment_preparation"]
|
|
58
|
+
id: str
|
|
59
|
+
fileId: str
|
|
60
|
+
|
|
61
|
+
|
|
56
62
|
|
|
57
63
|
class BotRequirement(TypedDict):
|
|
58
64
|
capabilityId: str
|
|
@@ -71,6 +77,22 @@ class BotAttachment(TypedDict, total=False):
|
|
|
71
77
|
facts: dict[str, Any]
|
|
72
78
|
warnings: list[dict[str, str]]
|
|
73
79
|
error: dict[str, str] | None
|
|
80
|
+
transcription: dict[Literal["state"], Literal["queued", "transcribing", "reconciling", "completed", "failed"]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BotAudioPlayback(TypedDict):
|
|
84
|
+
url: str
|
|
85
|
+
expiresAt: str
|
|
86
|
+
mediaType: Literal["audio/mpeg"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _BotFilePlayback(TypedDict, total=False):
|
|
90
|
+
playback: BotAudioPlayback
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BotFileLink(_BotFilePlayback):
|
|
94
|
+
url: str
|
|
95
|
+
expiresAt: str
|
|
74
96
|
|
|
75
97
|
|
|
76
98
|
class BotFileUpload(TypedDict):
|
|
@@ -114,7 +136,7 @@ class BotRun(TypedDict, total=False):
|
|
|
114
136
|
finalText: str | None
|
|
115
137
|
output: Any
|
|
116
138
|
error: Any
|
|
117
|
-
waitingOperation: BotConnectionAction | BotExecutionWait | None
|
|
139
|
+
waitingOperation: BotConnectionAction | BotExecutionWait | BotAttachmentPreparationWait | None
|
|
118
140
|
connection: BotConnectionAction | None
|
|
119
141
|
capabilities: list[BotCapabilityFact]
|
|
120
142
|
requirements: list[BotRequirement]
|
|
@@ -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.
|
|
40
|
+
STACKLINK_PYTHON_SDK_VERSION = "0.4.0"
|
|
41
41
|
STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
|
|
42
42
|
|
|
43
43
|
|
|
@@ -356,18 +356,12 @@ class Stacklink:
|
|
|
356
356
|
"user-agent": STACKLINK_PYTHON_USER_AGENT,
|
|
357
357
|
"x-stacklink-client-channel": "python_sdk",
|
|
358
358
|
"x-stacklink-client-name": "stacklink-python",
|
|
359
|
+
"x-stacklink-workbench-protocol": "2",
|
|
359
360
|
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
def _handle_response(self, status: int, headers: Mapping[str, str], body: Json) -> Json:
|
|
363
|
-
|
|
364
|
-
return body
|
|
365
|
-
message = body.get("message") if isinstance(body, dict) else None
|
|
366
|
-
code = body.get("code") if isinstance(body, dict) else None
|
|
367
|
-
details = body.get("details") if isinstance(body, dict) else None
|
|
368
|
-
if status == 429:
|
|
369
|
-
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
370
|
-
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
|
|
364
|
+
return _handle_response(status, headers, body)
|
|
371
365
|
|
|
372
366
|
def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
|
|
373
367
|
event_id = None
|
|
@@ -671,6 +665,7 @@ class AsyncStacklink:
|
|
|
671
665
|
"user-agent": STACKLINK_PYTHON_USER_AGENT,
|
|
672
666
|
"x-stacklink-client-channel": "python_sdk",
|
|
673
667
|
"x-stacklink-client-name": "stacklink-python",
|
|
668
|
+
"x-stacklink-workbench-protocol": "2",
|
|
674
669
|
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
675
670
|
}
|
|
676
671
|
|
|
@@ -874,9 +869,11 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
|
|
|
874
869
|
message = body.get("message") if isinstance(body, dict) else None
|
|
875
870
|
code = body.get("code") if isinstance(body, dict) else None
|
|
876
871
|
details = body.get("details") if isinstance(body, dict) else None
|
|
872
|
+
failure_id = body.get("failureId") if isinstance(body, dict) else None
|
|
873
|
+
failure_id = failure_id or headers.get("x-stacklink-failure-id")
|
|
877
874
|
if status == 429:
|
|
878
|
-
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
879
|
-
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
|
|
875
|
+
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
|
|
876
|
+
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
|
|
880
877
|
|
|
881
878
|
|
|
882
879
|
def _timeout_error(timeout_seconds: float, request_id: str) -> StacklinkError:
|
|
@@ -1,8 +1,9 @@
|
|
|
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, headers=None):
|
|
4
|
+
def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None, failure_id: str | None = None):
|
|
5
5
|
super().__init__(message)
|
|
6
|
+
self.failure_id = failure_id
|
|
6
7
|
self.status_code = status_code
|
|
7
8
|
self.code = code
|
|
8
9
|
self.details = details
|
|
@@ -10,7 +11,7 @@ class StacklinkError(Exception):
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class RateLimitError(StacklinkError):
|
|
13
|
-
def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None):
|
|
14
|
-
super().__init__(message, status_code=status_code, code=code, details=details)
|
|
14
|
+
def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None, failure_id: str | None = None):
|
|
15
|
+
super().__init__(message, status_code=status_code, code=code, details=details, headers=headers, failure_id=failure_id)
|
|
15
16
|
self.headers = headers or {}
|
|
16
17
|
|
|
@@ -77,6 +77,7 @@ def unwrap_tool_result(response: Any):
|
|
|
77
77
|
status_code=400,
|
|
78
78
|
code=str(error.get("code")) if error.get("code") is not None else None,
|
|
79
79
|
details=error.get("data"),
|
|
80
|
+
failure_id=error["data"].get("failureId") if isinstance(error.get("data"), dict) else None,
|
|
80
81
|
)
|
|
81
82
|
result = response.get("result")
|
|
82
83
|
if not isinstance(result, dict):
|
|
@@ -87,7 +88,9 @@ def unwrap_tool_result(response: Any):
|
|
|
87
88
|
for item in result.get("content", [])
|
|
88
89
|
if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str)
|
|
89
90
|
)
|
|
90
|
-
|
|
91
|
+
structured = result.get("structuredContent")
|
|
92
|
+
failure = structured.get("error") if isinstance(structured, dict) else None
|
|
93
|
+
raise StacklinkError(text or "Stacklink MCP tool call failed.", status_code=400, code="mcp_tool_error", details=result, failure_id=failure.get("failureId") if isinstance(failure, dict) else None)
|
|
91
94
|
if "structuredContent" in result:
|
|
92
95
|
return result["structuredContent"]
|
|
93
96
|
content = result.get("content")
|
|
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import time
|
|
5
5
|
import threading
|
|
6
|
+
from uuid import uuid4
|
|
6
7
|
from email.utils import parsedate_to_datetime
|
|
7
8
|
import httpx
|
|
8
|
-
from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
|
|
9
|
+
from typing import Any, BinaryIO, Iterator, Literal, Mapping, TypedDict, cast
|
|
9
10
|
from urllib.error import HTTPError
|
|
10
11
|
from urllib.request import Request, urlopen
|
|
11
12
|
|
|
@@ -319,8 +320,21 @@ class WorkbenchOutputPreview(TypedDict):
|
|
|
319
320
|
preview: str
|
|
320
321
|
|
|
321
322
|
|
|
323
|
+
class WorkbenchStorageOperation(TypedDict, total=False):
|
|
324
|
+
operationId: str
|
|
325
|
+
workbenchSessionId: str
|
|
326
|
+
kind: Literal["delete", "upload_finalize", "artifact_promote"]
|
|
327
|
+
status: Literal["pending", "processing", "completed", "failed"]
|
|
328
|
+
statusUrl: str
|
|
329
|
+
stage: str
|
|
330
|
+
retryAfterMs: int
|
|
331
|
+
pendingReason: str
|
|
332
|
+
result: dict[str, Any]
|
|
333
|
+
error: Any
|
|
334
|
+
|
|
335
|
+
|
|
322
336
|
class WorkbenchFile(TypedDict, total=False):
|
|
323
|
-
id: str
|
|
337
|
+
id: str | None
|
|
324
338
|
path: str
|
|
325
339
|
contentType: str
|
|
326
340
|
sizeBytes: int | str
|
|
@@ -340,9 +354,12 @@ class WorkbenchFileResponse(TypedDict):
|
|
|
340
354
|
class WorkbenchFileListResponse(TypedDict, total=False):
|
|
341
355
|
files: list[WorkbenchFile]
|
|
342
356
|
runtimeFiles: list[WorkbenchFile]
|
|
357
|
+
nextCursor: str | None
|
|
358
|
+
authoritative: bool
|
|
343
359
|
|
|
344
360
|
|
|
345
361
|
class WorkbenchFileMutationResponse(TypedDict, total=False):
|
|
362
|
+
operation: WorkbenchStorageOperation
|
|
346
363
|
file: WorkbenchFile
|
|
347
364
|
uploadId: str
|
|
348
365
|
finalized: bool
|
|
@@ -365,13 +382,16 @@ class WorkbenchFileUploadPrepareResponse(TypedDict):
|
|
|
365
382
|
upload: WorkbenchFileSignedUpload
|
|
366
383
|
|
|
367
384
|
|
|
368
|
-
class WorkbenchFileSignedDownload(TypedDict):
|
|
385
|
+
class WorkbenchFileSignedDownload(TypedDict, total=False):
|
|
369
386
|
file: WorkbenchFile
|
|
370
387
|
downloadUrl: str
|
|
371
388
|
expiresAt: str
|
|
389
|
+
requiredHeaders: dict[str, str]
|
|
372
390
|
|
|
373
391
|
|
|
374
392
|
class WorkbenchArtifact(TypedDict, total=False):
|
|
393
|
+
operationId: str | None
|
|
394
|
+
workbenchSessionId: str
|
|
375
395
|
id: str
|
|
376
396
|
artifactRef: str
|
|
377
397
|
path: str
|
|
@@ -691,11 +711,11 @@ class WorkbenchFiles:
|
|
|
691
711
|
def __init__(self, sandbox: "SessionSandbox"):
|
|
692
712
|
self.sandbox = sandbox
|
|
693
713
|
|
|
694
|
-
def list(self) -> WorkbenchFileListResponse:
|
|
714
|
+
def list(self, *, cursor: str | None = None, limit: int = 100) -> WorkbenchFileListResponse:
|
|
695
715
|
return cast(WorkbenchFileListResponse, self.sandbox.client.request(
|
|
696
716
|
"GET",
|
|
697
717
|
f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
|
|
698
|
-
query={"sessionId": self.sandbox.platform_session_id},
|
|
718
|
+
query={"sessionId": self.sandbox.platform_session_id, "cursor": cursor, "limit": limit},
|
|
699
719
|
))
|
|
700
720
|
|
|
701
721
|
def read(self, path: str) -> WorkbenchFileResponse:
|
|
@@ -712,33 +732,42 @@ class WorkbenchFiles:
|
|
|
712
732
|
*,
|
|
713
733
|
content_type: str | None = None,
|
|
714
734
|
metadata: Mapping[str, Any] | None = None,
|
|
735
|
+
upload_id: str | None = None,
|
|
715
736
|
) -> WorkbenchFileResponse:
|
|
716
737
|
raw = content.encode("utf-8") if isinstance(content, str) else content
|
|
717
738
|
body = {
|
|
718
739
|
"sessionId": self.sandbox.platform_session_id,
|
|
719
740
|
"path": path,
|
|
741
|
+
"uploadId": upload_id or str(uuid4()),
|
|
720
742
|
"contentBase64": base64.b64encode(raw).decode("ascii"),
|
|
721
743
|
"metadata": dict(metadata or {}),
|
|
722
744
|
}
|
|
723
745
|
if content_type is not None:
|
|
724
746
|
body["contentType"] = content_type
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
f"/v1/workbench/sessions/{
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
747
|
+
workspace_id = self.sandbox.ensure_session_id()
|
|
748
|
+
try:
|
|
749
|
+
result = self.sandbox.client.request("PUT", f"/v1/workbench/sessions/{workspace_id}/files", json_body=body)
|
|
750
|
+
return cast(WorkbenchFileResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, workspace_id, result))
|
|
751
|
+
except StacklinkError as error:
|
|
752
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
753
|
+
raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
|
|
754
|
+
details={**details, "uploadId": body["uploadId"], "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
|
|
755
|
+
headers=error.headers, failure_id=error.failure_id) from error
|
|
756
|
+
|
|
757
|
+
def prepare_upload(self, path: str, *, content_type: str, size_bytes: int, upload_id: str | None = None) -> WorkbenchFileUploadPrepareResponse:
|
|
758
|
+
upload_id = upload_id or str(uuid4())
|
|
759
|
+
workspace_id = self.sandbox.ensure_session_id()
|
|
760
|
+
try:
|
|
761
|
+
return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
|
|
762
|
+
"POST", f"/v1/workbench/sessions/{workspace_id}/files/upload-url",
|
|
763
|
+
json_body={"sessionId": self.sandbox.platform_session_id, "path": path,
|
|
764
|
+
"contentType": content_type, "sizeBytes": size_bytes, "uploadId": upload_id},
|
|
765
|
+
))
|
|
766
|
+
except StacklinkError as error:
|
|
767
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
768
|
+
raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
|
|
769
|
+
details={**details, "uploadId": upload_id, "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
|
|
770
|
+
headers=error.headers, failure_id=error.failure_id) from error
|
|
742
771
|
|
|
743
772
|
def finalize_upload(
|
|
744
773
|
self,
|
|
@@ -746,7 +775,7 @@ class WorkbenchFiles:
|
|
|
746
775
|
*,
|
|
747
776
|
metadata: Mapping[str, Any] | None = None,
|
|
748
777
|
) -> WorkbenchFileMutationResponse:
|
|
749
|
-
|
|
778
|
+
result = self.sandbox.client.request(
|
|
750
779
|
"POST",
|
|
751
780
|
f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/finalize-upload",
|
|
752
781
|
json_body={
|
|
@@ -754,21 +783,31 @@ class WorkbenchFiles:
|
|
|
754
783
|
"uploadId": upload_id,
|
|
755
784
|
"metadata": dict(metadata or {}),
|
|
756
785
|
},
|
|
757
|
-
)
|
|
786
|
+
)
|
|
787
|
+
return cast(WorkbenchFileMutationResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
|
|
758
788
|
|
|
759
789
|
def upload_large(
|
|
760
790
|
self,
|
|
761
791
|
path: str,
|
|
762
|
-
content: str | bytes,
|
|
792
|
+
content: str | bytes | BinaryIO,
|
|
763
793
|
*,
|
|
764
794
|
content_type: str | None = None,
|
|
765
795
|
metadata: Mapping[str, Any] | None = None,
|
|
796
|
+
transfer_timeout_seconds: float = 300.0,
|
|
797
|
+
upload_id: str | None = None,
|
|
766
798
|
) -> WorkbenchFileMutationResponse:
|
|
767
799
|
raw = content.encode("utf-8") if isinstance(content, str) else content
|
|
768
800
|
resolved_content_type = content_type or (
|
|
769
801
|
"text/plain; charset=utf-8" if isinstance(content, str) else "application/octet-stream"
|
|
770
802
|
)
|
|
771
|
-
|
|
803
|
+
if isinstance(raw, bytes):
|
|
804
|
+
size_bytes = len(raw)
|
|
805
|
+
else:
|
|
806
|
+
position = raw.tell()
|
|
807
|
+
raw.seek(0, 2)
|
|
808
|
+
size_bytes = raw.tell() - position
|
|
809
|
+
raw.seek(position)
|
|
810
|
+
prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=size_bytes, upload_id=upload_id)
|
|
772
811
|
upload = prepared.get("upload") if isinstance(prepared, dict) else None
|
|
773
812
|
if (
|
|
774
813
|
not isinstance(upload, dict)
|
|
@@ -777,13 +816,16 @@ class WorkbenchFiles:
|
|
|
777
816
|
):
|
|
778
817
|
raise StacklinkError("Stacklink did not return a signed Workbench upload URL.")
|
|
779
818
|
headers = upload.get("requiredHeaders") if isinstance(upload.get("requiredHeaders"), dict) else {}
|
|
780
|
-
request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={str(key): str(value) for key, value in headers.items()})
|
|
819
|
+
request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={**{str(key): str(value) for key, value in headers.items()}, "Content-Length": str(size_bytes)})
|
|
781
820
|
try:
|
|
782
|
-
with urlopen(request, timeout=
|
|
821
|
+
with urlopen(request, timeout=transfer_timeout_seconds) as response:
|
|
783
822
|
if response.status >= 400:
|
|
784
823
|
raise StacklinkError(f"Workbench file upload failed with {response.status}.")
|
|
785
824
|
except HTTPError as error:
|
|
786
|
-
|
|
825
|
+
if error.code != 412:
|
|
826
|
+
raise StacklinkError(f"Workbench file upload failed with {error.code}.", status_code=error.code, code="workbench_file_direct_upload_failed", details={"uploadId": upload["uploadId"], "workbenchSessionId": self.sandbox.ensure_session_id(), "sessionId": self.sandbox.platform_session_id}) from error
|
|
827
|
+
except (TimeoutError, OSError) as error:
|
|
828
|
+
raise StacklinkError("Upload outcome is unknown. Finalize the same uploadId to reconcile.", code="workbench_upload_outcome_unknown", details={"uploadId": upload["uploadId"]}) from error
|
|
787
829
|
return self.finalize_upload(
|
|
788
830
|
upload["uploadId"],
|
|
789
831
|
metadata=metadata,
|
|
@@ -799,7 +841,7 @@ class WorkbenchFiles:
|
|
|
799
841
|
if not isinstance(download_url, str):
|
|
800
842
|
raise StacklinkError("Stacklink did not return a signed Workbench download URL.")
|
|
801
843
|
try:
|
|
802
|
-
with urlopen(Request(download_url, method="GET"), timeout=
|
|
844
|
+
with urlopen(Request(download_url, method="GET", headers=signed.get("requiredHeaders", {})), timeout=300.0) as response:
|
|
803
845
|
return response.read()
|
|
804
846
|
except HTTPError as error:
|
|
805
847
|
raise StacklinkError(f"Workbench file download failed with {error.code}.") from error
|
|
@@ -1010,18 +1052,27 @@ class WorkbenchArtifacts:
|
|
|
1010
1052
|
label: str | None = None,
|
|
1011
1053
|
content_type: str | None = None,
|
|
1012
1054
|
metadata: Mapping[str, Any] | None = None,
|
|
1055
|
+
artifact_ref: str | None = None,
|
|
1013
1056
|
) -> WorkbenchArtifactResponse:
|
|
1014
1057
|
body: dict[str, Any] = {
|
|
1015
1058
|
"sessionId": self.sandbox.platform_session_id,
|
|
1016
1059
|
"workbenchSessionId": self.sandbox.ensure_session_id(),
|
|
1017
1060
|
"path": path,
|
|
1061
|
+
"artifactRef": artifact_ref or f"wba_{uuid4()}",
|
|
1018
1062
|
"metadata": dict(metadata or {}),
|
|
1019
1063
|
}
|
|
1020
1064
|
if label is not None:
|
|
1021
1065
|
body["label"] = label
|
|
1022
1066
|
if content_type is not None:
|
|
1023
1067
|
body["contentType"] = content_type
|
|
1024
|
-
|
|
1068
|
+
try:
|
|
1069
|
+
result = self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body)
|
|
1070
|
+
return cast(WorkbenchArtifactResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
|
|
1071
|
+
except StacklinkError as error:
|
|
1072
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
1073
|
+
raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
|
|
1074
|
+
details={**details, "artifactRef": body["artifactRef"], "workbenchSessionId": body["workbenchSessionId"], "sessionId": body["sessionId"]},
|
|
1075
|
+
headers=error.headers, failure_id=error.failure_id) from error
|
|
1025
1076
|
|
|
1026
1077
|
def download_url(self, artifact_id: str) -> WorkbenchArtifactDownloadResponse:
|
|
1027
1078
|
return cast(WorkbenchArtifactDownloadResponse, self.sandbox.client.workbench.artifact_download_url(artifact_id))
|
|
@@ -1134,6 +1185,10 @@ class WorkbenchResource:
|
|
|
1134
1185
|
|
|
1135
1186
|
def run(self, body: dict[str, Any]):
|
|
1136
1187
|
response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
|
|
1188
|
+
if isinstance(response.get("operation"), dict):
|
|
1189
|
+
operation = response["operation"]
|
|
1190
|
+
completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
|
|
1191
|
+
return {**{key: value for key, value in response.items() if key != "operation"}, **completed}
|
|
1137
1192
|
if body.get("operation") == "run_code" and body.get("background") is not True:
|
|
1138
1193
|
return _wait_for_workbench_response(self.client, body, response)
|
|
1139
1194
|
return response
|
|
@@ -1162,12 +1217,30 @@ class WorkbenchResource:
|
|
|
1162
1217
|
"operation": "destroy",
|
|
1163
1218
|
})
|
|
1164
1219
|
|
|
1220
|
+
def operation(self, session_id: str, workbench_session_id: str, operation_id: str):
|
|
1221
|
+
return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}/operations/{operation_id}", query={"sessionId": session_id})
|
|
1222
|
+
|
|
1223
|
+
def wait_operation(self, session_id: str, workbench_session_id: str, operation_id: str, *, timeout_seconds: float = 300.0):
|
|
1224
|
+
deadline = time.monotonic() + timeout_seconds
|
|
1225
|
+
while time.monotonic() < deadline:
|
|
1226
|
+
try:
|
|
1227
|
+
value = self.operation(session_id, workbench_session_id, operation_id)["operation"]
|
|
1228
|
+
except StacklinkError as error:
|
|
1229
|
+
raise StacklinkError("Operation observation stopped. Resume with the same operation ID.", status_code=error.status_code, code="workbench_operation_observation_failed", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id, "causeCode": error.code}) from error
|
|
1230
|
+
if value["status"] == "completed":
|
|
1231
|
+
return value["result"]
|
|
1232
|
+
if value["status"] == "failed":
|
|
1233
|
+
raise StacklinkError("Workbench operation needs attention.", code="workbench_operation_failed", details=value)
|
|
1234
|
+
time.sleep(min(max(0.1, value.get("retryAfterMs", 1000) / 1000), max(0, deadline - time.monotonic())))
|
|
1235
|
+
raise StacklinkError("Workbench operation is still running. Resume with its operation ID.", code="workbench_operation_pending", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id})
|
|
1236
|
+
|
|
1237
|
+
def finish_operation(self, session_id: str, workbench_session_id: str, result):
|
|
1238
|
+
operation = result.get("operation") if isinstance(result, dict) else None
|
|
1239
|
+
return self.wait_operation(session_id, workbench_session_id, operation["operationId"]) if isinstance(operation, dict) else result
|
|
1240
|
+
|
|
1165
1241
|
def delete_session(self, session_id: str, workbench_session_id: str) -> WorkbenchSessionDeleteReceipt:
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
f"/v1/workbench/sessions/{workbench_session_id}",
|
|
1169
|
-
query={"sessionId": session_id},
|
|
1170
|
-
))
|
|
1242
|
+
result = self.client.request("DELETE", f"/v1/workbench/sessions/{workbench_session_id}", query={"sessionId": session_id})
|
|
1243
|
+
return cast(WorkbenchSessionDeleteReceipt, self.finish_operation(session_id, workbench_session_id, result))
|
|
1171
1244
|
|
|
1172
1245
|
def sessions(self, session_id: str):
|
|
1173
1246
|
return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
|
|
@@ -20,7 +20,7 @@ from stacklink import (
|
|
|
20
20
|
create_stacklink_claude_agent_tool_config,
|
|
21
21
|
create_stacklink_openai_agents_tools,
|
|
22
22
|
)
|
|
23
|
-
from stacklink.workbench import WorkbenchRun
|
|
23
|
+
from stacklink.workbench import WorkbenchRun, SessionSandbox
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
@contextmanager
|
|
@@ -63,6 +63,52 @@ def stalled_stream_server(*, final_result=False, execution_page=False):
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
class StacklinkClientTests(unittest.TestCase):
|
|
66
|
+
def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
|
|
67
|
+
bodies = []
|
|
68
|
+
def transport(method, path, headers, body, query, timeout):
|
|
69
|
+
bodies.append(body)
|
|
70
|
+
return 504, {}, {"code": "outbound_http_timeout", "message": "Lost acknowledgement"}
|
|
71
|
+
client = Stacklink(api_key="test", transport=transport, max_attempts=1)
|
|
72
|
+
sandbox = SessionSandbox(client, "session-1")
|
|
73
|
+
sandbox.workbench_session_id = "workspace-1"
|
|
74
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
75
|
+
sandbox.files.upload("नतीजा.bin", b"result")
|
|
76
|
+
upload_id = caught.exception.details["uploadId"]
|
|
77
|
+
with self.assertRaises(StacklinkError):
|
|
78
|
+
sandbox.files.upload("नतीजा.bin", b"result", upload_id=upload_id)
|
|
79
|
+
self.assertEqual(bodies[0]["uploadId"], bodies[1]["uploadId"])
|
|
80
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
81
|
+
sandbox.artifacts.promote("नतीजा.bin")
|
|
82
|
+
artifact_ref = caught.exception.details["artifactRef"]
|
|
83
|
+
with self.assertRaises(StacklinkError):
|
|
84
|
+
sandbox.artifacts.promote("नतीजा.bin", artifact_ref=artifact_ref)
|
|
85
|
+
self.assertEqual(bodies[2]["artifactRef"], bodies[3]["artifactRef"])
|
|
86
|
+
|
|
87
|
+
def test_workbench_storage_wait_retains_identity_and_never_repeats_deletion(self):
|
|
88
|
+
calls = []
|
|
89
|
+
receipt = {"deleted": True, "databaseDeleted": True, "workbenchSessionId": "workspace-1"}
|
|
90
|
+
def transport(method, path, headers, body, query, timeout):
|
|
91
|
+
calls.append((method, path))
|
|
92
|
+
if method == "DELETE":
|
|
93
|
+
return 202, {}, {"operation": {"operationId": "operation-1"}}
|
|
94
|
+
return 200, {}, {"operation": {"operationId": "operation-1", "status": "completed", "result": receipt}}
|
|
95
|
+
client = Stacklink(api_key="test", transport=transport)
|
|
96
|
+
self.assertEqual(client.workbench.delete_session("session-1", "workspace-1"), receipt)
|
|
97
|
+
self.assertEqual([method for method, _ in calls], ["DELETE", "GET"])
|
|
98
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
99
|
+
client.workbench.wait_operation("session-1", "workspace-1", "operation-1", timeout_seconds=0)
|
|
100
|
+
self.assertEqual(caught.exception.details, {"sessionId": "session-1", "workbenchSessionId": "workspace-1", "operationId": "operation-1"})
|
|
101
|
+
self.assertEqual(len(calls), 2)
|
|
102
|
+
|
|
103
|
+
def test_workbench_storage_observation_failure_keeps_resume_handle(self):
|
|
104
|
+
def transport(method, path, headers, body, query, timeout):
|
|
105
|
+
return 403, {}, {"code": "access_denied", "message": "denied"}
|
|
106
|
+
client = Stacklink(api_key="test", transport=transport)
|
|
107
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
108
|
+
client.workbench.wait_operation("session-1", "workspace-1", "operation-1")
|
|
109
|
+
self.assertEqual(caught.exception.code, "workbench_operation_observation_failed")
|
|
110
|
+
self.assertEqual(caught.exception.details["operationId"], "operation-1")
|
|
111
|
+
|
|
66
112
|
def test_bot_avatar_recipe_is_preserved_for_sync_and_async_create(self):
|
|
67
113
|
recipe = {"version": 1, "style": "clay", "seed": "Sales assistant", "tone": "mint"}
|
|
68
114
|
payloads = []
|
|
@@ -804,6 +850,23 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
804
850
|
lines.extend(decoder.finish())
|
|
805
851
|
self.assertEqual(lines, ['data: {"value":"नमस्ते"}', ''])
|
|
806
852
|
|
|
853
|
+
def test_failure_reference_survives_http_and_mcp_errors(self):
|
|
854
|
+
from stacklink.mcp import unwrap_tool_result
|
|
855
|
+
for status, body in [(502, {"failureId": "failure-body"}), (429, {})]:
|
|
856
|
+
def transport(method, path, headers, request_body, query, timeout):
|
|
857
|
+
return status, {"x-stacklink-failure-id": "failure-header"}, body
|
|
858
|
+
client = Stacklink(api_key="ppk_test", transport=transport)
|
|
859
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
860
|
+
client.tools.search({"sessionId": "session", "query": "gmail"})
|
|
861
|
+
self.assertEqual(caught.exception.failure_id, body.get("failureId", "failure-header"))
|
|
862
|
+
for response in [
|
|
863
|
+
{"error": {"code": -32603, "data": {"failureId": "failure-mcp"}}},
|
|
864
|
+
{"result": {"isError": True, "structuredContent": {"error": {"failureId": "failure-mcp"}}}},
|
|
865
|
+
]:
|
|
866
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
867
|
+
unwrap_tool_result(response)
|
|
868
|
+
self.assertEqual(caught.exception.failure_id, "failure-mcp")
|
|
869
|
+
|
|
807
870
|
def test_rate_limit_error_is_typed(self):
|
|
808
871
|
def transport(method, path, headers, body, query, timeout):
|
|
809
872
|
return 429, {"Retry-After": "60"}, {"code": "rate_limited", "message": "Too many requests."}
|
|
@@ -994,6 +1057,9 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
994
1057
|
self.assertEqual(run.response["result"]["stdout"], "ready")
|
|
995
1058
|
self.assertEqual(run.response["execution"], run.response["executionLog"])
|
|
996
1059
|
self.assertEqual(run.response["result"], run.response["execution"]["result"])
|
|
1060
|
+
upload_id = calls[2][2].pop("uploadId")
|
|
1061
|
+
self.assertIsInstance(upload_id, str)
|
|
1062
|
+
self.assertEqual(len(upload_id), 36)
|
|
997
1063
|
self.assertEqual(calls, [
|
|
998
1064
|
("GET", "/v1/sessions/session-1/bootstrap", None, {}),
|
|
999
1065
|
("POST", "/v1/workbench/sessions", {
|
|
@@ -1009,7 +1075,7 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
1009
1075
|
"metadata": {},
|
|
1010
1076
|
"contentType": "text/plain",
|
|
1011
1077
|
}, {}),
|
|
1012
|
-
("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1"}),
|
|
1078
|
+
("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1", "limit": 100}),
|
|
1013
1079
|
("POST", "/v1/workbench/run", {
|
|
1014
1080
|
"language": "python",
|
|
1015
1081
|
"code": "print('ready')",
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|