stacklink-sdk 0.2.0__tar.gz → 0.3.7__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/PKG-INFO +111 -0
- stacklink_sdk-0.3.7/README.md +92 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/pyproject.toml +2 -2
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/__init__.py +18 -1
- stacklink_sdk-0.3.7/stacklink/approvals.py +118 -0
- stacklink_sdk-0.3.7/stacklink/bots.py +266 -0
- stacklink_sdk-0.3.7/stacklink/client.py +896 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/connected_accounts.py +2 -3
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/executions.py +23 -1
- stacklink_sdk-0.3.7/stacklink/mcp.py +112 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/memory.py +9 -2
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/openai_memory.py +240 -41
- stacklink_sdk-0.3.7/stacklink/providers.py +25 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/sessions.py +104 -22
- stacklink_sdk-0.3.7/stacklink/tools.py +40 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/types.py +56 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/webhooks.py +20 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/workbench.py +48 -38
- stacklink_sdk-0.3.7/stacklink_sdk.egg-info/PKG-INFO +111 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/SOURCES.txt +3 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/requires.txt +1 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/tests/test_client.py +515 -162
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/tests/test_google_docs_provider.py +6 -4
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/tests/test_google_drive_provider.py +6 -4
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/tests/test_google_sheets_provider.py +6 -4
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/tests/test_openai_memory.py +88 -1
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/tests/test_zoho_books_provider.py +6 -4
- stacklink_sdk-0.2.0/PKG-INFO +0 -71
- stacklink_sdk-0.2.0/README.md +0 -53
- stacklink_sdk-0.2.0/stacklink/client.py +0 -292
- stacklink_sdk-0.2.0/stacklink/mcp.py +0 -76
- stacklink_sdk-0.2.0/stacklink/tools.py +0 -144
- stacklink_sdk-0.2.0/stacklink_sdk.egg-info/PKG-INFO +0 -71
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/LICENSE +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/setup.cfg +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/api_keys.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/artifacts.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/auth_configs.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/billing.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/claude_agent.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/errors.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/knowledge.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/logs.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/openai_agents.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/projects.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink/triggers.py +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
- {stacklink_sdk-0.2.0 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stacklink-sdk
|
|
3
|
+
Version: 0.3.7
|
|
4
|
+
Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
|
|
5
|
+
Author: Stacklink
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Documentation, https://docs.stacklink.in/docs/quickstart
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: httpx[http2]<1,>=0.27
|
|
12
|
+
Provides-Extra: openai
|
|
13
|
+
Requires-Dist: openai<3,>=2.0; extra == "openai"
|
|
14
|
+
Provides-Extra: openai-agents
|
|
15
|
+
Requires-Dist: openai-agents<0.19,>=0.18; extra == "openai-agents"
|
|
16
|
+
Provides-Extra: claude-agent
|
|
17
|
+
Requires-Dist: claude-agent-sdk<0.3,>=0.2; extra == "claude-agent"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Stacklink Python SDK
|
|
21
|
+
|
|
22
|
+
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
23
|
+
|
|
24
|
+
Install the public package with the harness extra you use:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add 'stacklink-sdk[openai-agents]'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
or:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add 'stacklink-sdk[claude-agent]'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The complete guides and certified recipes live at [docs.stacklink.in](https://docs.stacklink.in/docs/quickstart). PyPI is the package delivery system, not the primary documentation surface. Stacklink does not display registry download badges in its product or documentation.
|
|
37
|
+
|
|
38
|
+
## Certified agent harnesses
|
|
39
|
+
|
|
40
|
+
| Harness | Stacklink adapter |
|
|
41
|
+
|---|---|
|
|
42
|
+
| OpenAI Agents SDK | `create_stacklink_openai_agents_tools(session)` |
|
|
43
|
+
| Claude Agent SDK | `create_stacklink_claude_agent_tool_config(session)` |
|
|
44
|
+
|
|
45
|
+
Both start with one explicit Stacklink session:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from stacklink import Stacklink
|
|
50
|
+
|
|
51
|
+
stacklink = Stacklink(
|
|
52
|
+
api_key=os.environ['STACKLINK_API_KEY'],
|
|
53
|
+
base_url=os.getenv('STACKLINK_BASE_URL', 'https://api.stacklink.in'),
|
|
54
|
+
)
|
|
55
|
+
session = stacklink.sessions.create_bound({
|
|
56
|
+
'externalTenantId': 'acme',
|
|
57
|
+
'externalUserId': 'user_123',
|
|
58
|
+
'modelSurface': {'mode': 'router'},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Pass the selected Stacklink adapter output to your agent harness.
|
|
63
|
+
pass
|
|
64
|
+
finally:
|
|
65
|
+
session.close()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The complete OpenAI Agents and Claude Agent examples shown by the Stacklink Overview are generated from the executable files under `examples/sdk/`. The dashboard, documentation, and certification tests all read those same files.
|
|
69
|
+
|
|
70
|
+
The raw OpenAI `with_stacklink_memory` helper remains a separate Memory integration. It is not the agent-tool integration and is not part of basic Overview onboarding.
|
|
71
|
+
|
|
72
|
+
Environment variables are `STACKLINK_API_KEY`, optional `STACKLINK_BASE_URL`, and the model-provider credentials required by the selected harness. The SDK is proprietary and is provided under the applicable Stacklink agreement.
|
|
73
|
+
|
|
74
|
+
## Accepted operations, approvals and retries
|
|
75
|
+
|
|
76
|
+
An execution response can be pending. Keep its `executionKind`, `executionId`, session ID and retry key in your application. Read or wait for that operation using the existing execution APIs. A lost connection does not submit a second action, and closing the application session can remove the authority required to execute pending work.
|
|
77
|
+
|
|
78
|
+
Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
|
|
79
|
+
|
|
80
|
+
For `waiting_for_approval`, read the shared request using `client.approvals` and `platform_approvals:read`. A decision also needs `platform_approvals:manage` and the authority required by that request. A server key alone cannot approve on behalf of a company person; eligible customer confirmation uses the verified session and its scoped grant. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
|
|
81
|
+
|
|
82
|
+
Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
|
|
83
|
+
|
|
84
|
+
Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.
|
|
85
|
+
|
|
86
|
+
## Hosted Bots (0.3.2)
|
|
87
|
+
|
|
88
|
+
Bots are saved, published agents with project-scoped configuration and durable runs. Use a server-held project API key with `bots:manage` to create/update/publish, and `bots:run` to invoke. Supply a session established from your authenticated customer; input variables do not grant identity or access. Both SDKs expose run status, replayable events, cancellation and callback delivery recovery. A callback failure does not rerun the model.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
bot = stacklink.bots.get(bot_id)
|
|
92
|
+
run = stacklink.bots.run(bot_id, session_id=session.id, message="Where is my order?", variables={"orderNumber": "4821"})
|
|
93
|
+
for event in stacklink.bots.stream(bot_id, run["id"]):
|
|
94
|
+
print(event)
|
|
95
|
+
result = stacklink.bots.get_run(bot_id, run["id"])
|
|
96
|
+
print(result["output"] or result["finalText"])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The async client provides the same operations with `await` and `async for`.
|
|
100
|
+
|
|
101
|
+
Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
|
|
102
|
+
|
|
103
|
+
## Shared approvals (0.3.3)
|
|
104
|
+
|
|
105
|
+
`client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
|
|
106
|
+
|
|
107
|
+
All enabled notification channels use the same stored request. Decision, notification delivery and execution have separate states. See the [approval guide](https://docs.stacklink.in/docs/reference/sdk-python#shared-approvals) for request and lifecycle details.
|
|
108
|
+
|
|
109
|
+
When project settings require Workbench code approval, a foreground code/bash call returns `waiting_for_approval`, `approvalRequestId`, and its original execution ID immediately. No runtime sandbox is allocated for that wait. After the authorized person decides, observe the original execution; do not submit the code again.
|
|
110
|
+
|
|
111
|
+
Server-side customer grant management (0.3.5): `get_customer_grant`, `save_customer_grant`, and `remove_customer_grant` operate on an authenticated customer session. Save exact tool keys and resource restrictions with the returned settings revision. The server supplies identity from the session; callers cannot choose another customer in the request body. These methods never grant company approval or bypass connected-account access.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Stacklink Python SDK
|
|
2
|
+
|
|
3
|
+
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
4
|
+
|
|
5
|
+
Install the public package with the harness extra you use:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add 'stacklink-sdk[openai-agents]'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
or:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv add 'stacklink-sdk[claude-agent]'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The complete guides and certified recipes live at [docs.stacklink.in](https://docs.stacklink.in/docs/quickstart). PyPI is the package delivery system, not the primary documentation surface. Stacklink does not display registry download badges in its product or documentation.
|
|
18
|
+
|
|
19
|
+
## Certified agent harnesses
|
|
20
|
+
|
|
21
|
+
| Harness | Stacklink adapter |
|
|
22
|
+
|---|---|
|
|
23
|
+
| OpenAI Agents SDK | `create_stacklink_openai_agents_tools(session)` |
|
|
24
|
+
| Claude Agent SDK | `create_stacklink_claude_agent_tool_config(session)` |
|
|
25
|
+
|
|
26
|
+
Both start with one explicit Stacklink session:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from stacklink import Stacklink
|
|
31
|
+
|
|
32
|
+
stacklink = Stacklink(
|
|
33
|
+
api_key=os.environ['STACKLINK_API_KEY'],
|
|
34
|
+
base_url=os.getenv('STACKLINK_BASE_URL', 'https://api.stacklink.in'),
|
|
35
|
+
)
|
|
36
|
+
session = stacklink.sessions.create_bound({
|
|
37
|
+
'externalTenantId': 'acme',
|
|
38
|
+
'externalUserId': 'user_123',
|
|
39
|
+
'modelSurface': {'mode': 'router'},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Pass the selected Stacklink adapter output to your agent harness.
|
|
44
|
+
pass
|
|
45
|
+
finally:
|
|
46
|
+
session.close()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The complete OpenAI Agents and Claude Agent examples shown by the Stacklink Overview are generated from the executable files under `examples/sdk/`. The dashboard, documentation, and certification tests all read those same files.
|
|
50
|
+
|
|
51
|
+
The raw OpenAI `with_stacklink_memory` helper remains a separate Memory integration. It is not the agent-tool integration and is not part of basic Overview onboarding.
|
|
52
|
+
|
|
53
|
+
Environment variables are `STACKLINK_API_KEY`, optional `STACKLINK_BASE_URL`, and the model-provider credentials required by the selected harness. The SDK is proprietary and is provided under the applicable Stacklink agreement.
|
|
54
|
+
|
|
55
|
+
## Accepted operations, approvals and retries
|
|
56
|
+
|
|
57
|
+
An execution response can be pending. Keep its `executionKind`, `executionId`, session ID and retry key in your application. Read or wait for that operation using the existing execution APIs. A lost connection does not submit a second action, and closing the application session can remove the authority required to execute pending work.
|
|
58
|
+
|
|
59
|
+
Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
|
|
60
|
+
|
|
61
|
+
For `waiting_for_approval`, read the shared request using `client.approvals` and `platform_approvals:read`. A decision also needs `platform_approvals:manage` and the authority required by that request. A server key alone cannot approve on behalf of a company person; eligible customer confirmation uses the verified session and its scoped grant. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
|
|
62
|
+
|
|
63
|
+
Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
|
|
64
|
+
|
|
65
|
+
Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.
|
|
66
|
+
|
|
67
|
+
## Hosted Bots (0.3.2)
|
|
68
|
+
|
|
69
|
+
Bots are saved, published agents with project-scoped configuration and durable runs. Use a server-held project API key with `bots:manage` to create/update/publish, and `bots:run` to invoke. Supply a session established from your authenticated customer; input variables do not grant identity or access. Both SDKs expose run status, replayable events, cancellation and callback delivery recovery. A callback failure does not rerun the model.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
bot = stacklink.bots.get(bot_id)
|
|
73
|
+
run = stacklink.bots.run(bot_id, session_id=session.id, message="Where is my order?", variables={"orderNumber": "4821"})
|
|
74
|
+
for event in stacklink.bots.stream(bot_id, run["id"]):
|
|
75
|
+
print(event)
|
|
76
|
+
result = stacklink.bots.get_run(bot_id, run["id"])
|
|
77
|
+
print(result["output"] or result["finalText"])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The async client provides the same operations with `await` and `async for`.
|
|
81
|
+
|
|
82
|
+
Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
|
|
83
|
+
|
|
84
|
+
## Shared approvals (0.3.3)
|
|
85
|
+
|
|
86
|
+
`client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
|
|
87
|
+
|
|
88
|
+
All enabled notification channels use the same stored request. Decision, notification delivery and execution have separate states. See the [approval guide](https://docs.stacklink.in/docs/reference/sdk-python#shared-approvals) for request and lifecycle details.
|
|
89
|
+
|
|
90
|
+
When project settings require Workbench code approval, a foreground code/bash call returns `waiting_for_approval`, `approvalRequestId`, and its original execution ID immediately. No runtime sandbox is allocated for that wait. After the authorized person decides, observe the original execution; do not submit the code again.
|
|
91
|
+
|
|
92
|
+
Server-side customer grant management (0.3.5): `get_customer_grant`, `save_customer_grant`, and `remove_customer_grant` operate on an authenticated customer session. Save exact tool keys and resource restrictions with the returned settings revision. The server supplies identity from the session; callers cannot choose another customer in the request body. These methods never grant company approval or bypass connected-account access.
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stacklink-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.7"
|
|
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"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
authors = [{ name = "Stacklink" }]
|
|
14
|
-
dependencies = []
|
|
14
|
+
dependencies = ["httpx[http2]>=0.27,<1"]
|
|
15
15
|
|
|
16
16
|
[project.urls]
|
|
17
17
|
Documentation = "https://docs.stacklink.in/docs/quickstart"
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from .
|
|
3
|
+
from .bots import verify_bot_api_request, sign_bot_trigger_request
|
|
4
|
+
from .approvals import ApprovalDecision, ApprovalRecord, ApprovalEvent, ApprovalPermissions, CustomerApprovalGrantInput
|
|
5
|
+
from .webhooks import verify_webhook_signature
|
|
6
|
+
from .client import AsyncStacklink, STACKLINK_PYTHON_SDK_VERSION, Stacklink
|
|
4
7
|
from .claude_agent import create_stacklink_claude_agent_tool_config
|
|
5
8
|
from .errors import RateLimitError, StacklinkError
|
|
6
9
|
from .openai_agents import create_stacklink_openai_agents_tools
|
|
7
10
|
from .openai_memory import StacklinkMemoryLifecycleError, with_stacklink_memory
|
|
8
11
|
from .types import (
|
|
9
12
|
ProjectProvider,
|
|
13
|
+
ProviderCatalogItem,
|
|
14
|
+
ProviderCatalogPage,
|
|
15
|
+
ProviderToolCatalogItem,
|
|
16
|
+
ProviderToolCatalogPage,
|
|
10
17
|
ProviderMarketplaceMetadata,
|
|
11
18
|
ProviderProfile,
|
|
12
19
|
ProviderProfileCapabilityGroup,
|
|
@@ -20,7 +27,13 @@ from .types import (
|
|
|
20
27
|
)
|
|
21
28
|
|
|
22
29
|
__all__ = [
|
|
30
|
+
"ApprovalDecision", "ApprovalRecord", "ApprovalEvent", "ApprovalPermissions",
|
|
31
|
+
"CustomerApprovalGrantInput",
|
|
23
32
|
"Stacklink",
|
|
33
|
+
"verify_bot_api_request",
|
|
34
|
+
"sign_bot_trigger_request",
|
|
35
|
+
"verify_webhook_signature",
|
|
36
|
+
"AsyncStacklink",
|
|
24
37
|
"STACKLINK_PYTHON_SDK_VERSION",
|
|
25
38
|
"StacklinkError",
|
|
26
39
|
"RateLimitError",
|
|
@@ -29,6 +42,10 @@ __all__ = [
|
|
|
29
42
|
"create_stacklink_openai_agents_tools",
|
|
30
43
|
"create_stacklink_claude_agent_tool_config",
|
|
31
44
|
"ProjectProvider",
|
|
45
|
+
"ProviderCatalogItem",
|
|
46
|
+
"ProviderCatalogPage",
|
|
47
|
+
"ProviderToolCatalogItem",
|
|
48
|
+
"ProviderToolCatalogPage",
|
|
32
49
|
"ProviderMarketplaceMetadata",
|
|
33
50
|
"ProviderProfile",
|
|
34
51
|
"ProviderProfileCapabilityGroup",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Shared approvals. Decisions apply to the stored action; never submit that action again."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Literal, TypedDict, Iterator, AsyncIterator
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApprovalDecision(TypedDict):
|
|
11
|
+
requirement: Literal['customer', 'company']
|
|
12
|
+
decision: Literal['approve', 'decline']
|
|
13
|
+
expectedRevision: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CustomerApprovalGrantInput(TypedDict):
|
|
17
|
+
revision: int
|
|
18
|
+
name: str
|
|
19
|
+
toolKeys: list[str]
|
|
20
|
+
accountIds: list[str]
|
|
21
|
+
resources: list[dict[str, str | int | float | bool]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApprovalPermissions(TypedDict):
|
|
25
|
+
canDecide: bool
|
|
26
|
+
canManage: bool
|
|
27
|
+
requirement: Literal['customer', 'company'] | None
|
|
28
|
+
reason: str | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApprovalRecord(TypedDict):
|
|
32
|
+
id: str
|
|
33
|
+
projectId: str
|
|
34
|
+
sessionId: str | None
|
|
35
|
+
revision: int
|
|
36
|
+
status: Literal['pending', 'approved', 'applying', 'applied', 'apply_failed', 'denied', 'cancelled', 'expired']
|
|
37
|
+
audience: Literal['approvers', 'customer', 'customer-approvers']
|
|
38
|
+
requirements: list[dict[str, Any]]
|
|
39
|
+
permissions: ApprovalPermissions
|
|
40
|
+
execution: dict[str, Any]
|
|
41
|
+
deliveries: list[dict[str, Any]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ApprovalEvent(TypedDict):
|
|
45
|
+
sequence: str
|
|
46
|
+
approvalId: str
|
|
47
|
+
type: Literal['approval.request.updated']
|
|
48
|
+
data: dict[str, Any]
|
|
49
|
+
occurredAt: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ApprovalsResource:
|
|
53
|
+
def __init__(self, client, *, async_mode: bool = False):
|
|
54
|
+
self.client = client
|
|
55
|
+
self.async_mode = async_mode
|
|
56
|
+
|
|
57
|
+
def get_customer_grant(self, *, session_id: str):
|
|
58
|
+
"""Server-only: identity is resolved from this authenticated customer session."""
|
|
59
|
+
return self.client.request('GET', '/v1/approvals/customer-grant', query={'sessionId': session_id})
|
|
60
|
+
|
|
61
|
+
def save_customer_grant(self, grant: CustomerApprovalGrantInput, *, session_id: str):
|
|
62
|
+
return self.client.request('PUT', '/v1/approvals/customer-grant', query={'sessionId': session_id}, json_body=grant)
|
|
63
|
+
|
|
64
|
+
def remove_customer_grant(self, *, session_id: str, revision: int):
|
|
65
|
+
return self.client.request('DELETE', '/v1/approvals/customer-grant', query={'sessionId': session_id}, json_body={'revision': revision})
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _query(session_id: str, as_customer: bool) -> dict[str, Any]:
|
|
69
|
+
return {'sessionId': session_id, 'asCustomer': 'true' if as_customer else 'false'}
|
|
70
|
+
|
|
71
|
+
def list(self, *, session_id: str, as_customer: bool = False, **filters):
|
|
72
|
+
return self.client.request('GET', '/v1/approvals', query={**filters, **self._query(session_id, as_customer)})
|
|
73
|
+
|
|
74
|
+
def get(self, approval_id: str, *, session_id: str, as_customer: bool = False):
|
|
75
|
+
return self.client.request('GET', '/v1/approvals/' + quote(approval_id, safe=''), query=self._query(session_id, as_customer))
|
|
76
|
+
|
|
77
|
+
def decide(self, approval_id: str, decision: ApprovalDecision, *, session_id: str, as_customer: bool = False, reason: str | None = None):
|
|
78
|
+
"""Call only after a current authorized person confirms the displayed exact proposal."""
|
|
79
|
+
body = dict(decision)
|
|
80
|
+
if reason is not None:
|
|
81
|
+
body['reason'] = reason
|
|
82
|
+
return self.client.request('POST', '/v1/approvals/' + quote(approval_id, safe='') + '/decide', query=self._query(session_id, as_customer), json_body=body)
|
|
83
|
+
|
|
84
|
+
def change(self, approval_id: str, *, session_id: str, expected_revision: int, message: str, as_customer: bool = False):
|
|
85
|
+
return self.client.request('POST', '/v1/approvals/' + quote(approval_id, safe='') + '/change', query=self._query(session_id, as_customer), json_body={'expectedRevision': expected_revision, 'message': message})
|
|
86
|
+
|
|
87
|
+
def events(self, *, session_id: str, after: str = '0', as_customer: bool = False, timeout_ms: int = 15_000):
|
|
88
|
+
return self.client.request('GET', '/v1/approvals/events', query={**self._query(session_id, as_customer), 'after': after}, timeout_ms=timeout_ms)
|
|
89
|
+
|
|
90
|
+
def observe(self, *, session_id: str, after: str = '0', as_customer: bool = False, timeout_ms: int = 300_000) -> Iterator[ApprovalEvent] | AsyncIterator[ApprovalEvent]:
|
|
91
|
+
"""Save each sequence and pass it back as after when reconnecting. Close the iterator to stop."""
|
|
92
|
+
if self.async_mode:
|
|
93
|
+
return self._observe_async(session_id, after, as_customer, timeout_ms)
|
|
94
|
+
return self._observe_sync(session_id, after, as_customer, timeout_ms)
|
|
95
|
+
|
|
96
|
+
def _observe_sync(self, session_id: str, cursor: str, as_customer: bool, timeout_ms: int) -> Iterator[ApprovalEvent]:
|
|
97
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
98
|
+
while time.monotonic() < deadline:
|
|
99
|
+
page = self.events(session_id=session_id, after=cursor, as_customer=as_customer, timeout_ms=max(1, min(15_000, int((deadline - time.monotonic()) * 1000))))
|
|
100
|
+
for event in page['events']:
|
|
101
|
+
if int(event['sequence']) <= int(cursor):
|
|
102
|
+
continue
|
|
103
|
+
cursor = event['sequence']
|
|
104
|
+
yield event
|
|
105
|
+
if len(page['events']) < 100:
|
|
106
|
+
time.sleep(max(0, min(1, deadline - time.monotonic())))
|
|
107
|
+
|
|
108
|
+
async def _observe_async(self, session_id: str, cursor: str, as_customer: bool, timeout_ms: int) -> AsyncIterator[ApprovalEvent]:
|
|
109
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
110
|
+
while time.monotonic() < deadline:
|
|
111
|
+
page = await self.events(session_id=session_id, after=cursor, as_customer=as_customer, timeout_ms=max(1, min(15_000, int((deadline - time.monotonic()) * 1000))))
|
|
112
|
+
for event in page['events']:
|
|
113
|
+
if int(event['sequence']) <= int(cursor):
|
|
114
|
+
continue
|
|
115
|
+
cursor = event['sequence']
|
|
116
|
+
yield event
|
|
117
|
+
if len(page['events']) < 100:
|
|
118
|
+
await asyncio.sleep(max(0, min(1, deadline - time.monotonic())))
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Hosted bot management and durable runs for sync and async clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Any
|
|
4
|
+
from contextlib import closing, aclosing
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
import httpx
|
|
13
|
+
from .errors import StacklinkError
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sign_bot_trigger_request(*, trigger_id: str, secret: str, body: str, request_id: str | None = None, now: float | None = None) -> dict[str, str]:
|
|
19
|
+
"""Sign the exact JSON bytes. Reuse request_id when retrying the same event."""
|
|
20
|
+
timestamp = str(int(time.time() if now is None else now))
|
|
21
|
+
request_id = request_id or str(uuid4())
|
|
22
|
+
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
23
|
+
signed = json.dumps(["stacklink-trigger-v1", trigger_id, timestamp, request_id, digest], separators=(",", ":"), ensure_ascii=False)
|
|
24
|
+
signature = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
25
|
+
return {"content-type": "application/json", "stacklink-timestamp": timestamp, "stacklink-request-id": request_id, "stacklink-signature": signature}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify_bot_api_request(*, method: str, url: str, body: str, headers: dict[str, str], project_id: str, keys: dict[str, str | dict], consume_request_id, now: float | None = None, tolerance_seconds: int = 300):
|
|
29
|
+
"""Verify raw request content; consume_request_id(id, expires_at) must atomically reject replays."""
|
|
30
|
+
normalized = {key.lower(): value for key, value in headers.items()}
|
|
31
|
+
def get(name):
|
|
32
|
+
return normalized.get("x-stacklink-" + name, "")
|
|
33
|
+
key_id, timestamp, request_id = get("key-id"), get("timestamp"), get("request-id")
|
|
34
|
+
secret, tenant, user = keys.get(key_id), get("tenant"), get("customer")
|
|
35
|
+
current_time = time.time() if now is None else now
|
|
36
|
+
if isinstance(secret, dict):
|
|
37
|
+
expiry = secret.get("validUntil")
|
|
38
|
+
if expiry is not None:
|
|
39
|
+
try:
|
|
40
|
+
if datetime.fromisoformat(expiry[:-1] + '+00:00' if expiry.endswith('Z') else expiry).timestamp() <= current_time:
|
|
41
|
+
return None
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
return None
|
|
44
|
+
secret = secret.get("secret")
|
|
45
|
+
try:
|
|
46
|
+
seconds = int(timestamp)
|
|
47
|
+
except (ValueError, TypeError):
|
|
48
|
+
return None
|
|
49
|
+
if not isinstance(secret, str) or not secret or not tenant or not request_id or get("project") != project_id or abs(current_time - seconds) > tolerance_seconds:
|
|
50
|
+
return None
|
|
51
|
+
payload = json.dumps(["stacklink-api-v1", method.upper(), url, request_id, timestamp, key_id, project_id, tenant, user, hashlib.sha256(body.encode("utf-8")).hexdigest()], ensure_ascii=False, separators=(",", ":"))
|
|
52
|
+
expected = "v1," + base64.b64encode(hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
|
|
53
|
+
if not hmac.compare_digest(get("signature"), expected):
|
|
54
|
+
return None
|
|
55
|
+
if not consume_request_id(request_id, seconds + tolerance_seconds):
|
|
56
|
+
return None
|
|
57
|
+
return {"tenantId": tenant, "userId": user or None, "requestId": request_id}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BotsResource:
|
|
61
|
+
def __init__(self, client, *, async_mode: bool = False):
|
|
62
|
+
self.client = client
|
|
63
|
+
self.async_mode = async_mode
|
|
64
|
+
|
|
65
|
+
def _path(self, bot_id: str, run_id: str | None = None) -> str:
|
|
66
|
+
path = f"/v1/bots/{quote(bot_id, safe='')}"
|
|
67
|
+
return f"{path}/runs/{quote(run_id, safe='')}" if run_id else path
|
|
68
|
+
|
|
69
|
+
def apis(self):
|
|
70
|
+
return self.client.request("GET", "/v1/bots/apis")
|
|
71
|
+
|
|
72
|
+
def update_api(self, api_id: str, revision: int, api: dict):
|
|
73
|
+
return self.client.request("PATCH", "/v1/bots/apis/" + quote(api_id, safe=""), json_body={"revision": revision, "api": api})
|
|
74
|
+
|
|
75
|
+
def delete_api(self, api_id: str):
|
|
76
|
+
return self.client.request("DELETE", "/v1/bots/apis/" + quote(api_id, safe=""))
|
|
77
|
+
|
|
78
|
+
def webhook_secret(self, bot_id: str):
|
|
79
|
+
return self.client.request("POST", self._path(bot_id) + "/webhook-secret", json_body={})
|
|
80
|
+
|
|
81
|
+
def trigger_webhook(self, bot_id: str, trigger_id: str):
|
|
82
|
+
return self.client.request("POST", self._path(bot_id) + "/triggers/" + quote(trigger_id, safe="") + "/webhook", json_body={})
|
|
83
|
+
|
|
84
|
+
def trigger_admission(self, bot_id: str, trigger_id: str, admission_id: str):
|
|
85
|
+
return self.client.request("GET", self._path(bot_id) + "/triggers/" + quote(trigger_id, safe="") + "/admissions/" + quote(admission_id, safe=""))
|
|
86
|
+
|
|
87
|
+
def delete_customer_data(self, bot_id: str, session_id: str):
|
|
88
|
+
"""Erase this bot's customer data; shared Memory and API credentials remain unchanged."""
|
|
89
|
+
return self.client.request("POST", self._path(bot_id) + "/customer-data/delete", json_body={"sessionId": session_id})
|
|
90
|
+
|
|
91
|
+
def api_signing_key(self, bot_id: str, api_id: str, *, action: str = "read", overlap_seconds: int = 86400, key_id: str | None = None):
|
|
92
|
+
body: dict = {"action": action}
|
|
93
|
+
if action == "prepare":
|
|
94
|
+
body["overlapSeconds"] = overlap_seconds
|
|
95
|
+
if action in ("activate", "cancel"):
|
|
96
|
+
body["keyId"] = key_id
|
|
97
|
+
return self.client.request("POST", self._path(bot_id) + "/apis/" + quote(api_id, safe="") + "/signing-key", json_body=body)
|
|
98
|
+
|
|
99
|
+
def models(self):
|
|
100
|
+
return self.client.request("GET", "/v1/bots/models")
|
|
101
|
+
|
|
102
|
+
def set_credential(self, bot_id: str, secret: str):
|
|
103
|
+
return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"secret": secret})
|
|
104
|
+
|
|
105
|
+
def deliveries(self, bot_id: str, run_id: str):
|
|
106
|
+
return self.client.request("GET", self._path(bot_id, run_id) + "/deliveries")
|
|
107
|
+
|
|
108
|
+
def retry_delivery(self, bot_id: str, run_id: str, delivery_id: str):
|
|
109
|
+
return self.client.request("POST", self._path(bot_id, run_id) + "/deliveries/retry", json_body={"deliveryId": delivery_id})
|
|
110
|
+
|
|
111
|
+
def list(self):
|
|
112
|
+
return self.client.request("GET", "/v1/bots")
|
|
113
|
+
|
|
114
|
+
def delete(self, bot_id: str):
|
|
115
|
+
return self.client.request("DELETE", self._path(bot_id))
|
|
116
|
+
|
|
117
|
+
def set_state(self, bot_id: str, state: str, *, pause_for: str = "manual", reason: str = ""):
|
|
118
|
+
return self.client.request("POST", self._path(bot_id) + "/state", json_body={"state": state, "pauseFor": pause_for, "reason": reason})
|
|
119
|
+
|
|
120
|
+
def create(self, name: str):
|
|
121
|
+
return self.client.request("POST", "/v1/bots", json_body={"name": name})
|
|
122
|
+
|
|
123
|
+
def set_customer_credential(self, bot_id: str, *, api_id: str, session_id: str, secret: str):
|
|
124
|
+
return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"apiId": api_id, "sessionId": session_id, "secret": secret})
|
|
125
|
+
|
|
126
|
+
def get(self, bot_id: str):
|
|
127
|
+
return self.client.request("GET", self._path(bot_id))
|
|
128
|
+
|
|
129
|
+
def update(self, bot_id: str, revision: int, patch: dict[str, Any]):
|
|
130
|
+
return self.client.request("PATCH", self._path(bot_id), json_body={"revision": revision, "patch": patch})
|
|
131
|
+
|
|
132
|
+
def publish(self, bot_id: str, revision: int, channels: dict[str, bool]):
|
|
133
|
+
return self.client.request("POST", self._path(bot_id) + "/publish", json_body={"revision": revision, "channels": channels})
|
|
134
|
+
|
|
135
|
+
def run(self, bot_id: str, *, session_id: str, message: str, variables: dict[str, Any] | None = None, conversation_id: str | None = None, run_id: str | None = None):
|
|
136
|
+
body: dict[str, Any] = {"sessionId": session_id, "message": message, "variables": variables or {}, "runId": run_id or str(uuid4())}
|
|
137
|
+
if conversation_id:
|
|
138
|
+
body["conversationId"] = conversation_id
|
|
139
|
+
return self.client.request("POST", self._path(bot_id) + "/runs", json_body=body)
|
|
140
|
+
|
|
141
|
+
def get_run(self, bot_id: str, run_id: str):
|
|
142
|
+
return self.client.request("GET", self._path(bot_id, run_id))
|
|
143
|
+
|
|
144
|
+
def wait(self, bot_id: str, run_id: str, *, total_timeout_ms: int = 300_000):
|
|
145
|
+
"""Return the finished run, or a run waiting for a person's approval or connection."""
|
|
146
|
+
if total_timeout_ms <= 0:
|
|
147
|
+
raise ValueError("total_timeout_ms must be positive")
|
|
148
|
+
return self._wait_async(bot_id, run_id, total_timeout_ms) if self.async_mode else self._wait_sync(bot_id, run_id, total_timeout_ms)
|
|
149
|
+
|
|
150
|
+
def _wait_sync(self, bot_id, run_id, total_timeout_ms):
|
|
151
|
+
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
152
|
+
while time.monotonic() < deadline:
|
|
153
|
+
run = self.get_run(bot_id, run_id)
|
|
154
|
+
if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
|
|
155
|
+
return run
|
|
156
|
+
time.sleep(min(1, max(0, deadline - time.monotonic())))
|
|
157
|
+
raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
|
|
158
|
+
|
|
159
|
+
async def _wait_async(self, bot_id, run_id, total_timeout_ms):
|
|
160
|
+
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
161
|
+
while time.monotonic() < deadline:
|
|
162
|
+
run = await self.get_run(bot_id, run_id)
|
|
163
|
+
if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
|
|
164
|
+
return run
|
|
165
|
+
await asyncio.sleep(min(1, max(0, deadline - time.monotonic())))
|
|
166
|
+
raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
|
|
167
|
+
|
|
168
|
+
def events(self, bot_id: str, run_id: str, *, after: str = "0"):
|
|
169
|
+
return self.client.request("GET", self._path(bot_id, run_id) + "/events", query={"after": after})
|
|
170
|
+
|
|
171
|
+
def stream(self, bot_id: str, run_id: str, *, after: str = "0", total_timeout_ms: int = 300_000):
|
|
172
|
+
method = self._stream_async if self.async_mode else self._stream_sync
|
|
173
|
+
return method(bot_id, run_id, after=after, total_timeout_ms=total_timeout_ms)
|
|
174
|
+
|
|
175
|
+
def _stream_sync(self, bot_id, run_id, *, after, total_timeout_ms):
|
|
176
|
+
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
177
|
+
cursor = after
|
|
178
|
+
while True:
|
|
179
|
+
remaining = _remaining(deadline, run_id, cursor)
|
|
180
|
+
try:
|
|
181
|
+
with closing(self.client.request_sse("GET", self._path(bot_id, run_id) + "/events/stream", headers={"Last-Event-ID": cursor}, total_timeout_ms=min(remaining, 30_000))) as stream:
|
|
182
|
+
for envelope in stream:
|
|
183
|
+
event = envelope.get("data")
|
|
184
|
+
if not isinstance(event, dict) or "sequence" not in event:
|
|
185
|
+
continue
|
|
186
|
+
_validate_event(event, run_id)
|
|
187
|
+
if int(event["sequence"]) <= int(cursor):
|
|
188
|
+
continue
|
|
189
|
+
cursor = event["sequence"]
|
|
190
|
+
yield event
|
|
191
|
+
if _terminal(event):
|
|
192
|
+
return
|
|
193
|
+
except (StacklinkError, httpx.TransportError) as error:
|
|
194
|
+
if not _retryable(error):
|
|
195
|
+
raise
|
|
196
|
+
page = self.events(bot_id, run_id, after=cursor)
|
|
197
|
+
for event in page["events"]:
|
|
198
|
+
_validate_event(event, run_id)
|
|
199
|
+
if int(event["sequence"]) <= int(cursor):
|
|
200
|
+
continue
|
|
201
|
+
cursor = event["sequence"]
|
|
202
|
+
yield event
|
|
203
|
+
if _terminal(event):
|
|
204
|
+
return
|
|
205
|
+
if page.get("terminal") and not page["events"]:
|
|
206
|
+
return
|
|
207
|
+
if len(page["events"]) < 200:
|
|
208
|
+
time.sleep(min(1, _remaining(deadline, run_id, cursor) / 1000))
|
|
209
|
+
|
|
210
|
+
async def _stream_async(self, bot_id, run_id, *, after, total_timeout_ms):
|
|
211
|
+
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
212
|
+
cursor = after
|
|
213
|
+
while True:
|
|
214
|
+
remaining = _remaining(deadline, run_id, cursor)
|
|
215
|
+
try:
|
|
216
|
+
async with aclosing(self.client.request_sse("GET", self._path(bot_id, run_id) + "/events/stream", headers={"Last-Event-ID": cursor}, total_timeout_ms=min(remaining, 30_000))) as stream:
|
|
217
|
+
async for envelope in stream:
|
|
218
|
+
event = envelope.get("data")
|
|
219
|
+
if not isinstance(event, dict) or "sequence" not in event:
|
|
220
|
+
continue
|
|
221
|
+
_validate_event(event, run_id)
|
|
222
|
+
if int(event["sequence"]) <= int(cursor):
|
|
223
|
+
continue
|
|
224
|
+
cursor = event["sequence"]
|
|
225
|
+
yield event
|
|
226
|
+
if _terminal(event):
|
|
227
|
+
return
|
|
228
|
+
except (StacklinkError, httpx.TransportError) as error:
|
|
229
|
+
if not _retryable(error):
|
|
230
|
+
raise
|
|
231
|
+
page = await self.events(bot_id, run_id, after=cursor)
|
|
232
|
+
for event in page["events"]:
|
|
233
|
+
_validate_event(event, run_id)
|
|
234
|
+
if int(event["sequence"]) <= int(cursor):
|
|
235
|
+
continue
|
|
236
|
+
cursor = event["sequence"]
|
|
237
|
+
yield event
|
|
238
|
+
if _terminal(event):
|
|
239
|
+
return
|
|
240
|
+
if page.get("terminal") and not page["events"]:
|
|
241
|
+
return
|
|
242
|
+
if len(page["events"]) < 200:
|
|
243
|
+
await asyncio.sleep(min(1, _remaining(deadline, run_id, cursor) / 1000))
|
|
244
|
+
|
|
245
|
+
def cancel(self, bot_id: str, run_id: str):
|
|
246
|
+
return self.client.request("POST", self._path(bot_id, run_id) + "/stop", json_body={})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _terminal(event):
|
|
250
|
+
return event.get("type") in ("run.completed", "run.failed", "run.stopped")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _validate_event(event, run_id):
|
|
254
|
+
if event.get("runId") != run_id:
|
|
255
|
+
raise StacklinkError("Bot event belongs to a different run.", status_code=502, code="bot_event_invalid")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _retryable(error):
|
|
259
|
+
return isinstance(error, httpx.TransportError) or (isinstance(error, StacklinkError) and error.status_code in (408, 429, 502, 503, 504) and error.code != "bot_event_invalid")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _remaining(deadline, run_id, cursor):
|
|
263
|
+
remaining = int((deadline - time.monotonic()) * 1000)
|
|
264
|
+
if remaining <= 0:
|
|
265
|
+
raise StacklinkError("Bot observation timed out. Resume the same run from the last sequence.", status_code=408, code="sdk_stream_timeout", details={"runId": run_id, "after": cursor})
|
|
266
|
+
return remaining
|