managed-deepagents 0.6.2.dev6__py3-none-win_amd64.whl → 0.6.2.dev7__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- managed_deepagents/__init__.py +13 -1
- managed_deepagents/_agent_auth.py +260 -0
- managed_deepagents/_agent_auth_http.py +119 -0
- managed_deepagents/_binaries/mda.exe +0 -0
- managed_deepagents/_channels/schedule_delivery.py +12 -59
- managed_deepagents/_channels/trigger/client.py +9 -37
- managed_deepagents/_channels/trigger/types.py +2 -5
- managed_deepagents/_connectors/mcp/__init__.py +12 -2
- managed_deepagents/_connectors/mcp/load.py +172 -6
- managed_deepagents/_credential_gate.py +372 -0
- managed_deepagents/_credentials.py +417 -0
- managed_deepagents/_identity_auth.py +1 -1
- managed_deepagents/_identity_runtime.py +310 -7
- managed_deepagents/_managed_middleware.py +10 -9
- managed_deepagents/_managed_tools.py +20 -23
- managed_deepagents/identity/__init__.py +58 -5
- managed_deepagents/runtime.py +31 -4
- {managed_deepagents-0.6.2.dev6.dist-info → managed_deepagents-0.6.2.dev7.dist-info}/METADATA +1 -1
- {managed_deepagents-0.6.2.dev6.dist-info → managed_deepagents-0.6.2.dev7.dist-info}/RECORD +23 -19
- {managed_deepagents-0.6.2.dev6.dist-info → managed_deepagents-0.6.2.dev7.dist-info}/WHEEL +0 -0
- {managed_deepagents-0.6.2.dev6.dist-info → managed_deepagents-0.6.2.dev7.dist-info}/entry_points.txt +0 -0
- {managed_deepagents-0.6.2.dev6.dist-info → managed_deepagents-0.6.2.dev7.dist-info}/licenses/LICENSE +0 -0
- {managed_deepagents-0.6.2.dev6.dist-info → managed_deepagents-0.6.2.dev7.dist-info}/top_level.txt +0 -0
managed_deepagents/__init__.py
CHANGED
|
@@ -9,12 +9,19 @@ This package provides two things:
|
|
|
9
9
|
|
|
10
10
|
Resources use ``define_*`` factories while provider collections use namespaces:
|
|
11
11
|
``define_sandbox()``, ``channels.slack(...)``, ``connectors.mcp(...)``,
|
|
12
|
-
and ``auth.supabase(...)``. Contributing a *new*
|
|
12
|
+
``credentials.get(...)``, and ``auth.supabase(...)``. Contributing a *new*
|
|
13
13
|
primitive is not part of the public surface.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
from managed_deepagents._credentials import (
|
|
19
|
+
CredentialAuthorizationRequiredError,
|
|
20
|
+
CredentialNotFoundError,
|
|
21
|
+
CredentialOwner,
|
|
22
|
+
CredentialStoreError,
|
|
23
|
+
credentials,
|
|
24
|
+
)
|
|
18
25
|
from managed_deepagents._managed_tools import (
|
|
19
26
|
ManagedDeepAgentRuntime,
|
|
20
27
|
)
|
|
@@ -31,6 +38,10 @@ from managed_deepagents.sandbox import SandboxDefinition
|
|
|
31
38
|
from managed_deepagents.schedules import define_schedule
|
|
32
39
|
|
|
33
40
|
__all__ = [
|
|
41
|
+
"CredentialAuthorizationRequiredError",
|
|
42
|
+
"CredentialNotFoundError",
|
|
43
|
+
"CredentialOwner",
|
|
44
|
+
"CredentialStoreError",
|
|
34
45
|
"DeepAgentDefinition",
|
|
35
46
|
"ManagedDeepAgentRuntime",
|
|
36
47
|
"MemoryDefinition",
|
|
@@ -38,6 +49,7 @@ __all__ = [
|
|
|
38
49
|
"auth",
|
|
39
50
|
"channels",
|
|
40
51
|
"connectors",
|
|
52
|
+
"credentials",
|
|
41
53
|
"define_deep_agent",
|
|
42
54
|
"define_identity",
|
|
43
55
|
"define_memory",
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Agent Auth client used by ``credentials.get`` and the pre-run credential gate.
|
|
2
|
+
|
|
3
|
+
Talks to the shipped Agent Auth APIs (credentials + oauth-authorization-sessions),
|
|
4
|
+
not the aspirational ``credentials/mint`` endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol, TypedDict
|
|
13
|
+
|
|
14
|
+
from managed_deepagents._agent_auth_http import (
|
|
15
|
+
AGENT_AUTH_TIMEOUT_SECONDS,
|
|
16
|
+
CredentialStoreError,
|
|
17
|
+
agent_auth_credential_query,
|
|
18
|
+
agent_auth_headers,
|
|
19
|
+
request_agent_auth_json,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
AGENT_AUTH_PROVIDER_PAGE_SIZE = 100
|
|
23
|
+
AGENT_AUTH_MAX_PROVIDER_PAGES = 20
|
|
24
|
+
|
|
25
|
+
_HTTP_BAD_REQUEST = 400
|
|
26
|
+
_HTTP_NOT_FOUND = 404
|
|
27
|
+
_HTTP_CONFLICT = 409
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AGENT_AUTH_TIMEOUT_SECONDS",
|
|
31
|
+
"AgentAuth",
|
|
32
|
+
"AgentAuthContext",
|
|
33
|
+
"AgentAuthUnknownProviderError",
|
|
34
|
+
"CredentialAuthorizationRequiredError",
|
|
35
|
+
"PendingAuthorization",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AgentAuthContext:
|
|
41
|
+
base_url: str
|
|
42
|
+
#: Deployment id, passed to oauth-authorization-sessions as ``agent_id``.
|
|
43
|
+
agent_id: str
|
|
44
|
+
#: Authenticated caller, used as ``owner_id`` for user credentials.
|
|
45
|
+
principal_id: str
|
|
46
|
+
api_key: str | None = None
|
|
47
|
+
workspace_id: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PendingAuthorization(TypedDict):
|
|
51
|
+
"""One credential slug the caller has not granted, with its connect URL."""
|
|
52
|
+
|
|
53
|
+
slug: str
|
|
54
|
+
connect_url: str
|
|
55
|
+
auth_id: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CredentialAuthorizationRequiredError(RuntimeError):
|
|
59
|
+
def __init__(self, slugs: list[str]) -> None:
|
|
60
|
+
super().__init__(
|
|
61
|
+
f"the caller has not connected: {', '.join(slugs)}. Declare the credential "
|
|
62
|
+
"on a connector so the pre-run gate can request it."
|
|
63
|
+
)
|
|
64
|
+
self.slugs = slugs
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AgentAuthUnknownProviderError(RuntimeError):
|
|
68
|
+
def __init__(self, slug: str) -> None:
|
|
69
|
+
super().__init__(
|
|
70
|
+
f'Agent Auth has no OAuth provider registered for slug "{slug}". '
|
|
71
|
+
"Register the provider or fix the credential slug."
|
|
72
|
+
)
|
|
73
|
+
self.slug = slug
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AgentAuthRequest(Protocol):
|
|
77
|
+
"""HTTP surface bound onto :class:`AgentAuth` (method, path, optional JSON body)."""
|
|
78
|
+
|
|
79
|
+
def __call__(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
path: str,
|
|
83
|
+
*,
|
|
84
|
+
body: dict[str, object] | None = None,
|
|
85
|
+
) -> tuple[int, dict[str, object]]: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AgentAuth:
|
|
89
|
+
"""Bound Agent Auth HTTP client. Headers, origin, and timeout live on the instance."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
context: AgentAuthContext,
|
|
94
|
+
*,
|
|
95
|
+
request: AgentAuthRequest | None = None,
|
|
96
|
+
timeout: float = AGENT_AUTH_TIMEOUT_SECONDS,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Bind this client to a caller.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
context: Caller/run identity and Agent Auth origin.
|
|
102
|
+
request: Testing only. Substitute for the default HTTP transport.
|
|
103
|
+
timeout: Testing only. Override the request budget in seconds.
|
|
104
|
+
"""
|
|
105
|
+
self.context = context
|
|
106
|
+
self._timeout = timeout
|
|
107
|
+
self._request: AgentAuthRequest = request if request is not None else self._urlopen_json
|
|
108
|
+
|
|
109
|
+
def read_user_access_token(self, slug: str) -> str | None:
|
|
110
|
+
"""Read the caller's OAuth access token without starting a session.
|
|
111
|
+
|
|
112
|
+
Reads never mint, so a caller can be probed repeatedly — on every replay
|
|
113
|
+
of an interrupted node, for instance — without leaving authorization
|
|
114
|
+
sessions behind.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The access token, or ``None`` when the caller must connect.
|
|
118
|
+
"""
|
|
119
|
+
credential_id = self.find_credential_id(
|
|
120
|
+
owner_type="user",
|
|
121
|
+
owner_id=self.context.principal_id,
|
|
122
|
+
kind="oauth2",
|
|
123
|
+
slug=slug,
|
|
124
|
+
)
|
|
125
|
+
if credential_id is None:
|
|
126
|
+
return None
|
|
127
|
+
return self._fetch_access_token(credential_id)
|
|
128
|
+
|
|
129
|
+
def start_authorization_session(self, slug: str) -> PendingAuthorization:
|
|
130
|
+
"""Start an OAuth session for a slug the caller has not granted."""
|
|
131
|
+
provider_id = self.resolve_oauth_provider_id(slug)
|
|
132
|
+
status, body = self._request(
|
|
133
|
+
"POST",
|
|
134
|
+
"/v1/agent-auth/oauth-authorization-sessions",
|
|
135
|
+
body={
|
|
136
|
+
"provider_id": provider_id,
|
|
137
|
+
"principal_id": self.context.principal_id,
|
|
138
|
+
"agent_id": self.context.agent_id,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
if status >= _HTTP_BAD_REQUEST:
|
|
142
|
+
raise CredentialStoreError(
|
|
143
|
+
"Agent Auth create oauth-authorization-session failed", status
|
|
144
|
+
)
|
|
145
|
+
auth_id = str(body.get("id") or "")
|
|
146
|
+
connect_url = str(body.get("verification_url") or "")
|
|
147
|
+
if not auth_id or not connect_url:
|
|
148
|
+
raise CredentialStoreError(
|
|
149
|
+
"Agent Auth oauth-authorization-session missing id or verification_url"
|
|
150
|
+
)
|
|
151
|
+
return {"slug": slug, "connect_url": connect_url, "auth_id": auth_id}
|
|
152
|
+
|
|
153
|
+
def resolve_oauth_provider_id(self, slug: str) -> str:
|
|
154
|
+
"""oauth-providers has no slug filter, so the match is made across pages."""
|
|
155
|
+
cursor: str | None = None
|
|
156
|
+
for _ in range(AGENT_AUTH_MAX_PROVIDER_PAGES):
|
|
157
|
+
query = {"page_size": str(AGENT_AUTH_PROVIDER_PAGE_SIZE)}
|
|
158
|
+
if cursor:
|
|
159
|
+
query["cursor"] = cursor
|
|
160
|
+
status, body = self._request(
|
|
161
|
+
"GET", f"/v1/agent-auth/oauth-providers?{urllib.parse.urlencode(query)}"
|
|
162
|
+
)
|
|
163
|
+
if status >= _HTTP_BAD_REQUEST:
|
|
164
|
+
raise CredentialStoreError("Agent Auth list oauth-providers failed", status)
|
|
165
|
+
raw_items = body.get("items")
|
|
166
|
+
for item in raw_items if isinstance(raw_items, list) else []:
|
|
167
|
+
if isinstance(item, Mapping) and item.get("slug") == slug and item.get("id"):
|
|
168
|
+
return str(item["id"])
|
|
169
|
+
next_cursor = body.get("next_cursor")
|
|
170
|
+
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
|
|
171
|
+
raise AgentAuthUnknownProviderError(slug)
|
|
172
|
+
cursor = next_cursor
|
|
173
|
+
raise CredentialStoreError("Agent Auth oauth-provider list returned too many pages")
|
|
174
|
+
|
|
175
|
+
def find_credential_id(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
owner_type: str,
|
|
179
|
+
owner_id: str,
|
|
180
|
+
kind: str,
|
|
181
|
+
slug: str,
|
|
182
|
+
) -> str | None:
|
|
183
|
+
"""Find a credential id by slug.
|
|
184
|
+
|
|
185
|
+
The credential key is ``credential_id``, never ``id``.
|
|
186
|
+
"""
|
|
187
|
+
query = agent_auth_credential_query(
|
|
188
|
+
owner_type=owner_type, owner_id=owner_id, kind=kind, slug=slug
|
|
189
|
+
)
|
|
190
|
+
status, body = self._request("GET", f"/v1/agent-auth/credentials?{query}")
|
|
191
|
+
if status >= _HTTP_BAD_REQUEST:
|
|
192
|
+
raise CredentialStoreError("Agent Auth list credentials failed", status)
|
|
193
|
+
items = body.get("items")
|
|
194
|
+
row = items[0] if isinstance(items, list) and items else None
|
|
195
|
+
credential_id = row.get("credential_id") if isinstance(row, Mapping) else None
|
|
196
|
+
return credential_id if isinstance(credential_id, str) else None
|
|
197
|
+
|
|
198
|
+
def read_secret(self, credential_id: str) -> str | None:
|
|
199
|
+
status, body = self._request(
|
|
200
|
+
"GET",
|
|
201
|
+
f"/v1/agent-auth/credentials/{_path_segment(credential_id)}/secret",
|
|
202
|
+
)
|
|
203
|
+
if status == _HTTP_NOT_FOUND:
|
|
204
|
+
return None
|
|
205
|
+
if status >= _HTTP_BAD_REQUEST:
|
|
206
|
+
raise CredentialStoreError("Agent Auth get secret failed", status)
|
|
207
|
+
secret = body.get("secret")
|
|
208
|
+
return secret if isinstance(secret, str) and secret else None
|
|
209
|
+
|
|
210
|
+
def _fetch_access_token(self, credential_id: str) -> str | None:
|
|
211
|
+
status, body = self._request(
|
|
212
|
+
"GET",
|
|
213
|
+
f"/v1/agent-auth/credentials/{_path_segment(credential_id)}/oauth2",
|
|
214
|
+
)
|
|
215
|
+
if _is_authorization_required(status, body):
|
|
216
|
+
return None
|
|
217
|
+
if status >= _HTTP_BAD_REQUEST:
|
|
218
|
+
raise CredentialStoreError("Agent Auth get oauth2 failed", status)
|
|
219
|
+
oauth2 = body.get("oauth2") if isinstance(body.get("oauth2"), dict) else body
|
|
220
|
+
token = oauth2.get("access_token") if isinstance(oauth2, dict) else None
|
|
221
|
+
return token if isinstance(token, str) and token else None
|
|
222
|
+
|
|
223
|
+
def _urlopen_json(
|
|
224
|
+
self,
|
|
225
|
+
method: str,
|
|
226
|
+
path: str,
|
|
227
|
+
*,
|
|
228
|
+
body: dict[str, object] | None = None,
|
|
229
|
+
) -> tuple[int, dict[str, object]]:
|
|
230
|
+
if not self.context.base_url.startswith(("http://", "https://")):
|
|
231
|
+
raise ValueError("Agent Auth URL must be an http(s) URL")
|
|
232
|
+
return request_agent_auth_json(
|
|
233
|
+
method,
|
|
234
|
+
f"{self.context.base_url}{path}",
|
|
235
|
+
body=body,
|
|
236
|
+
headers=self._headers(),
|
|
237
|
+
timeout=self._timeout,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _headers(self) -> dict[str, str]:
|
|
241
|
+
return agent_auth_headers(
|
|
242
|
+
api_key=self.context.api_key,
|
|
243
|
+
workspace_id=self.context.workspace_id,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _path_segment(value: str) -> str:
|
|
248
|
+
return urllib.parse.quote(value, safe="")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _is_authorization_required(status: int, body: dict[str, object]) -> bool:
|
|
252
|
+
"""Agent Auth answers 409 when a stored grant has lapsed and needs re-auth."""
|
|
253
|
+
if status != _HTTP_CONFLICT:
|
|
254
|
+
return False
|
|
255
|
+
detail = str(body.get("detail") or body.get("title") or body.get("error") or "").lower()
|
|
256
|
+
return (
|
|
257
|
+
body.get("error") == "authorization_required"
|
|
258
|
+
or "authorization is required" in detail
|
|
259
|
+
or "oauth authorization" in detail
|
|
260
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Shared Agent Auth HTTP transport and credential-list paging.
|
|
2
|
+
|
|
3
|
+
Used by ``credentials.get`` and the credential gate so both talk to the same
|
|
4
|
+
API with the same headers, redirect policy, and page walker.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from http.client import HTTPMessage
|
|
18
|
+
from typing import IO
|
|
19
|
+
|
|
20
|
+
AGENT_AUTH_TIMEOUT_SECONDS = 10.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CredentialStoreError(RuntimeError):
|
|
24
|
+
"""Agent Auth could not answer.
|
|
25
|
+
|
|
26
|
+
Callers pass a fixed message: a response body can quote secret material, so
|
|
27
|
+
it never reaches this error, the logs, or the traceback chain.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
31
|
+
self.status = status
|
|
32
|
+
super().__init__(message if status is None else f"{message} (status {status})")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AGENT_AUTH_TIMEOUT_SECONDS",
|
|
37
|
+
"CredentialStoreError",
|
|
38
|
+
"agent_auth_credential_query",
|
|
39
|
+
"agent_auth_headers",
|
|
40
|
+
"request_agent_auth_json",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def agent_auth_credential_query(
|
|
45
|
+
*,
|
|
46
|
+
owner_type: str,
|
|
47
|
+
owner_id: str,
|
|
48
|
+
kind: str,
|
|
49
|
+
slug: str,
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Query string that addresses one credential by its natural key."""
|
|
52
|
+
return urllib.parse.urlencode(
|
|
53
|
+
{"owner_type": owner_type, "owner_id": owner_id, "kind": kind, "slug": slug}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def agent_auth_headers(
|
|
58
|
+
*,
|
|
59
|
+
api_key: str | None = None,
|
|
60
|
+
workspace_id: str | None = None,
|
|
61
|
+
) -> dict[str, str]:
|
|
62
|
+
headers = {
|
|
63
|
+
"accept": "application/json",
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
"x-langsmith-product": "mda",
|
|
66
|
+
}
|
|
67
|
+
resolved_api_key = (api_key or "").strip()
|
|
68
|
+
if resolved_api_key:
|
|
69
|
+
headers["x-api-key"] = resolved_api_key
|
|
70
|
+
resolved_workspace = (workspace_id or "").strip()
|
|
71
|
+
if resolved_workspace:
|
|
72
|
+
headers["x-tenant-id"] = resolved_workspace
|
|
73
|
+
return headers
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _decode_json_object(raw: bytes) -> dict[str, object]:
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(raw.decode("utf-8") or "{}")
|
|
79
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
80
|
+
return {}
|
|
81
|
+
return payload if isinstance(payload, dict) else {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
85
|
+
def redirect_request(
|
|
86
|
+
self,
|
|
87
|
+
req: urllib.request.Request,
|
|
88
|
+
fp: IO[bytes],
|
|
89
|
+
code: int,
|
|
90
|
+
msg: str,
|
|
91
|
+
headers: HTTPMessage,
|
|
92
|
+
newurl: str,
|
|
93
|
+
) -> urllib.request.Request | None:
|
|
94
|
+
del req, fp, code, msg, headers, newurl
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def request_agent_auth_json(
|
|
99
|
+
method: str,
|
|
100
|
+
url: str,
|
|
101
|
+
*,
|
|
102
|
+
body: Mapping[str, object] | None = None,
|
|
103
|
+
headers: Mapping[str, str],
|
|
104
|
+
timeout: float,
|
|
105
|
+
) -> tuple[int, dict[str, object]]:
|
|
106
|
+
"""Issue one Agent Auth HTTP call: no redirects, bounded timeout, JSON body."""
|
|
107
|
+
data = None if body is None else json.dumps(dict(body)).encode("utf-8")
|
|
108
|
+
request = urllib.request.Request( # noqa: S310
|
|
109
|
+
url,
|
|
110
|
+
data=data,
|
|
111
|
+
headers=dict(headers),
|
|
112
|
+
method=method,
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
opener = urllib.request.build_opener(_NoRedirectHandler())
|
|
116
|
+
with opener.open(request, timeout=timeout) as response:
|
|
117
|
+
return int(response.status), _decode_json_object(response.read())
|
|
118
|
+
except urllib.error.HTTPError as exc:
|
|
119
|
+
return int(exc.code), _decode_json_object(exc.read())
|
|
Binary file
|
|
@@ -2,31 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
|
-
import uuid
|
|
7
7
|
from collections.abc import Awaitable, Callable, Mapping
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
|
|
10
10
|
|
|
11
11
|
from managed_deepagents._channels.assistant_text import extract_assistant_text
|
|
12
12
|
from managed_deepagents._channels.binding import SLACK_BOT_TOKEN_ENV
|
|
13
|
-
from managed_deepagents._channels.trigger.client import create_trigger_client
|
|
14
13
|
from managed_deepagents._identity_runtime import (
|
|
15
14
|
_ambient_configurable,
|
|
16
15
|
_configurable_from_runtime,
|
|
17
16
|
)
|
|
18
|
-
from managed_deepagents._log import logger
|
|
19
17
|
from managed_deepagents._memory.async_util import await_if_needed
|
|
20
18
|
|
|
21
19
|
if TYPE_CHECKING:
|
|
22
20
|
from langchain.agents.middleware.types import AgentMiddleware
|
|
23
21
|
|
|
24
|
-
from managed_deepagents._channels.trigger.types import TriggerChannelActionRequest
|
|
25
22
|
from managed_deepagents._channels.types import ChannelAddress
|
|
26
23
|
else:
|
|
27
24
|
# Authoring-light installs may lack langchain; annotations stay deferred.
|
|
28
25
|
AgentMiddleware = object
|
|
29
26
|
|
|
27
|
+
_LOG = logging.getLogger("mda")
|
|
28
|
+
|
|
30
29
|
_schedule_metadata_for_tests: Callable[[], Mapping[str, object] | None] | None = None
|
|
31
30
|
|
|
32
31
|
|
|
@@ -59,16 +58,6 @@ class NormalizedScheduleDeliverTo(TypedDict):
|
|
|
59
58
|
auto_post: bool
|
|
60
59
|
|
|
61
60
|
|
|
62
|
-
def get_deployment_id(env: Mapping[str, str] | None = None) -> str | None:
|
|
63
|
-
"""Return the deployment ID supplied by the managed runtime."""
|
|
64
|
-
resolved_env = os.environ if env is None else env
|
|
65
|
-
for key in ("LANGSMITH_HOST_PROJECT_ID", "LANGGRAPH_CLOUD_ID"):
|
|
66
|
-
value = (resolved_env.get(key) or "").strip()
|
|
67
|
-
if value:
|
|
68
|
-
return value
|
|
69
|
-
return None
|
|
70
|
-
|
|
71
|
-
|
|
72
61
|
def read_schedule_deliver_to( # noqa: PLR0911 - validation short-circuits
|
|
73
62
|
configurable: Mapping[str, object] | None,
|
|
74
63
|
) -> NormalizedScheduleDeliverTo | None:
|
|
@@ -133,7 +122,7 @@ def address_from_deliver_to(to: _ProviderConversation | _ProviderThread) -> Chan
|
|
|
133
122
|
return {"conversation_id": to["conversation_id"]}
|
|
134
123
|
|
|
135
124
|
|
|
136
|
-
async def deliver_schedule_result( # noqa:
|
|
125
|
+
async def deliver_schedule_result( # noqa: PLR0911 - soft-fail outcomes
|
|
137
126
|
*,
|
|
138
127
|
deliver_to: NormalizedScheduleDeliverTo,
|
|
139
128
|
reply_text: str,
|
|
@@ -145,9 +134,9 @@ async def deliver_schedule_result( # noqa: C901, PLR0911 - soft-fail outcomes
|
|
|
145
134
|
if not text or not deliver_to["auto_post"]:
|
|
146
135
|
return "skipped"
|
|
147
136
|
if deliver_to["channel"] != "slack":
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
137
|
+
_LOG.error(
|
|
138
|
+
"schedule deliver_to channel %r is not supported yet",
|
|
139
|
+
deliver_to["channel"],
|
|
151
140
|
)
|
|
152
141
|
return "failed"
|
|
153
142
|
|
|
@@ -162,47 +151,15 @@ async def deliver_schedule_result( # noqa: C901, PLR0911 - soft-fail outcomes
|
|
|
162
151
|
if hasattr(result, "__await__"):
|
|
163
152
|
await cast("Awaitable[object]", result)
|
|
164
153
|
return "posted"
|
|
165
|
-
except Exception
|
|
166
|
-
|
|
167
|
-
"schedule Slack delivery failed (%s)",
|
|
168
|
-
logger.safe_error_label(error),
|
|
169
|
-
)
|
|
170
|
-
return "failed"
|
|
171
|
-
|
|
172
|
-
deployment_id = get_deployment_id()
|
|
173
|
-
try:
|
|
174
|
-
client = create_trigger_client()
|
|
175
|
-
except ValueError:
|
|
176
|
-
client = None
|
|
177
|
-
if client is not None and deployment_id:
|
|
178
|
-
action: TriggerChannelActionRequest = {
|
|
179
|
-
"type": "post",
|
|
180
|
-
"action_id": str(uuid.uuid4()),
|
|
181
|
-
"address": dict(address),
|
|
182
|
-
"message": {"text": text},
|
|
183
|
-
}
|
|
184
|
-
try:
|
|
185
|
-
await client.post_deployment_action(
|
|
186
|
-
deployment_id,
|
|
187
|
-
deliver_to["channel"],
|
|
188
|
-
action,
|
|
189
|
-
)
|
|
190
|
-
return "posted"
|
|
191
|
-
except Exception as error: # noqa: BLE001 - soft-fail schedule delivery
|
|
192
|
-
logger.error(
|
|
193
|
-
"schedule Trigger channel delivery failed (%s)",
|
|
194
|
-
logger.safe_error_label(error),
|
|
195
|
-
)
|
|
154
|
+
except Exception: # noqa: BLE001 - soft-fail schedule delivery
|
|
155
|
+
_LOG.exception("schedule Slack delivery failed")
|
|
196
156
|
return "failed"
|
|
197
157
|
|
|
198
158
|
token = (
|
|
199
159
|
bot_token if bot_token is not None else os.environ.get(SLACK_BOT_TOKEN_ENV) or ""
|
|
200
160
|
).strip()
|
|
201
161
|
if not token:
|
|
202
|
-
|
|
203
|
-
"schedule deliver_to requires Trigger channel configuration or "
|
|
204
|
-
"SLACK_BOT_TOKEN; skipping auto-post"
|
|
205
|
-
)
|
|
162
|
+
_LOG.error("schedule deliver_to requires SLACK_BOT_TOKEN; skipping auto-post")
|
|
206
163
|
return "failed"
|
|
207
164
|
|
|
208
165
|
try:
|
|
@@ -219,11 +176,8 @@ async def deliver_schedule_result( # noqa: C901, PLR0911 - soft-fail outcomes
|
|
|
219
176
|
if hasattr(posted, "__await__"):
|
|
220
177
|
await cast("Awaitable[object]", posted)
|
|
221
178
|
return "posted"
|
|
222
|
-
except Exception
|
|
223
|
-
|
|
224
|
-
"schedule Slack delivery failed (%s)",
|
|
225
|
-
logger.safe_error_label(error),
|
|
226
|
-
)
|
|
179
|
+
except Exception: # noqa: BLE001 - soft-fail schedule delivery
|
|
180
|
+
_LOG.exception("schedule Slack delivery failed")
|
|
227
181
|
return "failed"
|
|
228
182
|
|
|
229
183
|
|
|
@@ -343,7 +297,6 @@ __all__ = [
|
|
|
343
297
|
"address_from_deliver_to",
|
|
344
298
|
"create_schedule_delivery_middleware",
|
|
345
299
|
"deliver_schedule_result",
|
|
346
|
-
"get_deployment_id",
|
|
347
300
|
"is_trusted_schedule_delivery_run",
|
|
348
301
|
"read_schedule_deliver_to",
|
|
349
302
|
]
|
|
@@ -93,41 +93,18 @@ class TriggerClient(TriggerClientLike):
|
|
|
93
93
|
channel_id: str,
|
|
94
94
|
body: TriggerChannelActionRequest,
|
|
95
95
|
) -> TriggerChannelActionResponse:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
return _parse_action_response(response)
|
|
103
|
-
|
|
104
|
-
async def post_deployment_action(
|
|
105
|
-
self,
|
|
106
|
-
deployment_id: str,
|
|
107
|
-
channel_name: str,
|
|
108
|
-
body: TriggerChannelActionRequest,
|
|
109
|
-
) -> TriggerChannelActionResponse:
|
|
110
|
-
query = urllib.parse.urlencode(
|
|
111
|
-
{"deployment_id": deployment_id, "channel_name": channel_name}
|
|
112
|
-
)
|
|
113
|
-
response = await self._post(
|
|
114
|
-
"actions",
|
|
115
|
-
f"{self._api_base}/channels/actions?{query}",
|
|
116
|
-
body,
|
|
117
|
-
)
|
|
118
|
-
return _parse_action_response(response)
|
|
96
|
+
response = await self._post("actions", channel_id, body)
|
|
97
|
+
response_id = response.get("id") if isinstance(response, dict) else None
|
|
98
|
+
if not isinstance(response_id, str) or not response_id.strip():
|
|
99
|
+
raise TriggerClientError("actions")
|
|
100
|
+
return {"id": response_id}
|
|
119
101
|
|
|
120
102
|
async def post_runtime_event(
|
|
121
103
|
self,
|
|
122
104
|
channel_id: str,
|
|
123
105
|
body: TriggerChannelRuntimeEventRequest,
|
|
124
106
|
) -> TriggerChannelRuntimeEventResponse:
|
|
125
|
-
|
|
126
|
-
response = await self._post(
|
|
127
|
-
"events",
|
|
128
|
-
f"{self._api_base}/channels/{encoded_channel_id}/events",
|
|
129
|
-
body,
|
|
130
|
-
)
|
|
107
|
+
response = await self._post("events", channel_id, body)
|
|
131
108
|
status = response.get("status") if isinstance(response, dict) else None
|
|
132
109
|
if not isinstance(status, str):
|
|
133
110
|
raise TriggerClientError("events")
|
|
@@ -136,9 +113,11 @@ class TriggerClient(TriggerClientLike):
|
|
|
136
113
|
async def _post(
|
|
137
114
|
self,
|
|
138
115
|
operation: TriggerClientOperation,
|
|
139
|
-
|
|
116
|
+
channel_id: str,
|
|
140
117
|
body: TriggerChannelActionRequest | TriggerChannelRuntimeEventRequest,
|
|
141
118
|
) -> object:
|
|
119
|
+
encoded_channel_id = urllib.parse.quote(channel_id, safe="")
|
|
120
|
+
url = f"{self._api_base}/channels/{encoded_channel_id}/{operation}"
|
|
142
121
|
headers = {
|
|
143
122
|
"content-type": "application/json",
|
|
144
123
|
"x-api-key": self._api_key,
|
|
@@ -165,13 +144,6 @@ class TriggerClient(TriggerClientLike):
|
|
|
165
144
|
raise TriggerClientError(operation) from None
|
|
166
145
|
|
|
167
146
|
|
|
168
|
-
def _parse_action_response(response: object) -> TriggerChannelActionResponse:
|
|
169
|
-
response_id = response.get("id") if isinstance(response, dict) else None
|
|
170
|
-
if not isinstance(response_id, str) or not response_id.strip():
|
|
171
|
-
raise TriggerClientError("actions")
|
|
172
|
-
return {"id": response_id}
|
|
173
|
-
|
|
174
|
-
|
|
175
147
|
def create_trigger_client(
|
|
176
148
|
*,
|
|
177
149
|
user_id: str | None = None,
|
|
@@ -66,15 +66,12 @@ class TriggerChannelActionMessage(TypedDict):
|
|
|
66
66
|
text: str
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
class
|
|
69
|
+
class TriggerChannelActionRequest(TypedDict):
|
|
70
70
|
type: Literal["post"]
|
|
71
71
|
action_id: str
|
|
72
72
|
address: TriggerChannelAddress
|
|
73
|
-
message: TriggerChannelActionMessage
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
class TriggerChannelActionRequest(TriggerChannelActionRequestRequired, total=False):
|
|
77
73
|
source_thread_key: str
|
|
74
|
+
message: TriggerChannelActionMessage
|
|
78
75
|
|
|
79
76
|
|
|
80
77
|
class TriggerChannelActionResponse(TypedDict):
|