managed-deepagents 0.9.3.dev4__py3-none-win_amd64.whl → 0.10.0.dev2__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.
Files changed (31) hide show
  1. managed_deepagents/__init__.py +32 -0
  2. managed_deepagents/_agent_auth.py +42 -20
  3. managed_deepagents/_binaries/mda.exe +0 -0
  4. managed_deepagents/_channels/github.py +534 -0
  5. managed_deepagents/_channels/github_connections.py +15 -0
  6. managed_deepagents/_channels/github_events.py +28 -0
  7. managed_deepagents/_channels/github_schema.py +279 -0
  8. managed_deepagents/_channels/github_types.py +173 -0
  9. managed_deepagents/_channels/http/__init__.py +39 -1
  10. managed_deepagents/_channels/http/http.py +175 -34
  11. managed_deepagents/_channels/http/ingress.py +153 -13
  12. managed_deepagents/_channels/http/threads.py +16 -0
  13. managed_deepagents/_channels/http/types.py +32 -1
  14. managed_deepagents/_channels/reserved_connections.py +42 -0
  15. managed_deepagents/_channels/run_input.py +127 -0
  16. managed_deepagents/_channels/trigger/ingress.py +2 -15
  17. managed_deepagents/_connections.py +61 -1
  18. managed_deepagents/_credential_gate.py +35 -2
  19. managed_deepagents/_identity_auth.py +112 -11
  20. managed_deepagents/_identity_runtime.py +24 -0
  21. managed_deepagents/_ingress_runs.py +153 -10
  22. managed_deepagents/_runtime_schedules.py +10 -7
  23. managed_deepagents/_schedule_auth.py +1 -55
  24. managed_deepagents/namespaces.py +2 -0
  25. managed_deepagents/runtime.py +4 -3
  26. {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/METADATA +168 -4
  27. {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/RECORD +31 -23
  28. {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/WHEEL +0 -0
  29. {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/entry_points.txt +0 -0
  30. {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/licenses/LICENSE +0 -0
  31. {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/top_level.txt +0 -0
@@ -15,6 +15,19 @@ primitive is not part of the public surface.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ from managed_deepagents._channels.github_types import (
19
+ GitHubAppOptions,
20
+ GitHubChannelRuntime,
21
+ GitHubEvent,
22
+ GitHubEventCallback,
23
+ GitHubEventResult,
24
+ GitHubHandlers,
25
+ GitHubLabeledIssuePayload,
26
+ GitHubPayload,
27
+ GitHubPullRequestPayload,
28
+ GitHubWorkflowPayload,
29
+ SupportedEventKey,
30
+ )
18
31
  from managed_deepagents._channels.http import (
19
32
  HttpChannel,
20
33
  HttpChannelInboundMessage,
@@ -23,6 +36,11 @@ from managed_deepagents._channels.http import (
23
36
  HttpChannelRequest,
24
37
  HttpPostedMessage,
25
38
  )
39
+ from managed_deepagents._channels.run_input import (
40
+ ChannelRunInput,
41
+ ChannelRunOptions,
42
+ ChannelRunResult,
43
+ )
26
44
  from managed_deepagents._channels.types import (
27
45
  ChannelContentMessage,
28
46
  ChannelEvent,
@@ -102,11 +120,24 @@ __all__ = [
102
120
  "ChannelInfo",
103
121
  "ChannelMessage",
104
122
  "ChannelNativeMessage",
123
+ "ChannelRunInput",
124
+ "ChannelRunOptions",
125
+ "ChannelRunResult",
105
126
  "ConnectionNotFoundError",
106
127
  "CredentialAuthorizationRequiredError",
107
128
  "CredentialStoreError",
108
129
  "DeepAgentDefinition",
109
130
  "GeneratedSkill",
131
+ "GitHubAppOptions",
132
+ "GitHubChannelRuntime",
133
+ "GitHubEvent",
134
+ "GitHubEventCallback",
135
+ "GitHubEventResult",
136
+ "GitHubHandlers",
137
+ "GitHubLabeledIssuePayload",
138
+ "GitHubPayload",
139
+ "GitHubPullRequestPayload",
140
+ "GitHubWorkflowPayload",
110
141
  "HttpChannel",
111
142
  "HttpChannelInboundMessage",
112
143
  "HttpChannelParseResult",
@@ -144,6 +175,7 @@ __all__ = [
144
175
  "SkillSelection",
145
176
  "SlackPromptResponse",
146
177
  "SseMcpServerConfig",
178
+ "SupportedEventKey",
147
179
  "auth",
148
180
  "basic",
149
181
  "bearer",
@@ -185,11 +185,28 @@ class AgentAuth:
185
185
  return principal_id
186
186
 
187
187
  def find_workspace_connection(self, slug: str) -> WorkspaceConnection | None:
188
- """List workspace connections and return the row for ``slug``, if any."""
188
+ """Look up the workspace connection row for ``slug``, if any."""
189
189
  if not (self.context.workspace_id or "").strip():
190
190
  raise CredentialStoreError(
191
191
  "connections are not configured: LANGSMITH_WORKSPACE_ID is not set"
192
192
  )
193
+ status, body = self._request(
194
+ "GET",
195
+ f"/v1/agent-auth/connections?{urllib.parse.urlencode({'slug': slug})}",
196
+ )
197
+ if status >= _HTTP_BAD_REQUEST:
198
+ raise CredentialStoreError("Agent Auth list connections failed", status, body)
199
+ items = _connection_items(body)
200
+ for item in items:
201
+ if item.get("slug") == slug:
202
+ return _workspace_connection_row(item)
203
+ # Older Agent Auth ignores ``slug`` and returns the first page of all rows.
204
+ if items:
205
+ return self._page_workspace_connection(slug)
206
+ return None
207
+
208
+ def _page_workspace_connection(self, slug: str) -> WorkspaceConnection | None:
209
+ """Page through all workspace connections for the row matching ``slug``."""
193
210
  cursor: str | None = None
194
211
  for _ in range(AGENT_AUTH_MAX_PROVIDER_PAGES):
195
212
  query: dict[str, str] = {
@@ -203,25 +220,9 @@ class AgentAuth:
203
220
  )
204
221
  if status >= _HTTP_BAD_REQUEST:
205
222
  raise CredentialStoreError("Agent Auth list connections failed", status, body)
206
- raw_items = body.get("items")
207
- items = raw_items if isinstance(raw_items, list) else []
208
- for item in items:
209
- if not isinstance(item, Mapping) or item.get("slug") != slug:
210
- continue
211
- connection_id = (
212
- item.get("id") or item.get("connection_id") or item.get("credential_id")
213
- )
214
- kind = _connection_credential_kind(item)
215
- owner_policy = item.get("owner_policy")
216
- if not isinstance(connection_id, str) or not connection_id:
217
- raise CredentialStoreError("Agent Auth connection row missing id or kind")
218
- if not isinstance(kind, str) or not kind:
219
- raise CredentialStoreError("Agent Auth connection row missing id or kind")
220
- return WorkspaceConnection(
221
- connection_id=connection_id,
222
- kind=kind,
223
- owner_policy=str(owner_policy) if isinstance(owner_policy, str) else "",
224
- )
223
+ for item in _connection_items(body):
224
+ if item.get("slug") == slug:
225
+ return _workspace_connection_row(item)
225
226
  next_cursor = body.get("next_cursor")
226
227
  if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
227
228
  return None
@@ -493,6 +494,27 @@ def _path_segment(value: str) -> str:
493
494
  return urllib.parse.quote(value, safe="")
494
495
 
495
496
 
497
+ def _connection_items(body: Mapping[str, object]) -> list[Mapping[str, object]]:
498
+ """Object rows of a connection list response."""
499
+ raw_items = body.get("items")
500
+ if not isinstance(raw_items, list):
501
+ return []
502
+ return [item for item in raw_items if isinstance(item, Mapping)]
503
+
504
+
505
+ def _workspace_connection_row(item: Mapping[str, object]) -> WorkspaceConnection:
506
+ connection_id = item.get("id") or item.get("connection_id") or item.get("credential_id")
507
+ kind = _connection_credential_kind(item)
508
+ owner_policy = item.get("owner_policy")
509
+ if not isinstance(connection_id, str) or not connection_id or not kind:
510
+ raise CredentialStoreError("Agent Auth connection row missing id or kind")
511
+ return WorkspaceConnection(
512
+ connection_id=connection_id,
513
+ kind=kind,
514
+ owner_policy=owner_policy if isinstance(owner_policy, str) else "",
515
+ )
516
+
517
+
496
518
  def _connection_credential_kind(item: Mapping[str, object]) -> str:
497
519
  credential = item.get("credential")
498
520
  if isinstance(credential, Mapping):
Binary file
@@ -0,0 +1,534 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import hmac
6
+ import inspect
7
+ import json
8
+ import os
9
+ import re
10
+ import time
11
+ from collections.abc import Mapping
12
+ from dataclasses import asdict
13
+ from importlib import import_module
14
+ from typing import TYPE_CHECKING, Any, TypedDict, cast
15
+ from urllib.parse import quote, urlencode, urlsplit
16
+
17
+ import httpx
18
+ from langgraph_sdk.schema import MultitaskStrategy
19
+
20
+ from managed_deepagents._channels.github_connections import (
21
+ GITHUB_TOKEN_SLUG,
22
+ github_app_connection_slug,
23
+ )
24
+ from managed_deepagents._channels.github_events import github_event
25
+ from managed_deepagents._channels.github_schema import (
26
+ SUPPORTED_EVENT_KEYS,
27
+ github_identifier_adapter,
28
+ github_receipt_adapter,
29
+ )
30
+ from managed_deepagents._channels.github_types import (
31
+ GitHubAppOptions,
32
+ GitHubChannelRuntime,
33
+ GitHubEvent,
34
+ GitHubEventCallback,
35
+ GitHubHandlers,
36
+ GitHubRepository,
37
+ GitHubSender,
38
+ SupportedEventKey,
39
+ _ChannelName,
40
+ )
41
+ from managed_deepagents._channels.http import HttpChannel, channel_thread_id, http_channel
42
+ from managed_deepagents._channels.runtime import is_json_value
43
+ from managed_deepagents._log import logger
44
+
45
+ if TYPE_CHECKING:
46
+ from managed_deepagents._channels.http.types import (
47
+ HttpChannelInboundMessage,
48
+ HttpChannelParseResult,
49
+ HttpChannelRequest,
50
+ HttpChannelRunInfo,
51
+ JsonValue,
52
+ )
53
+ from managed_deepagents._channels.run_input import ChannelRunResult
54
+
55
+ _MAX_SAFE_INTEGER = 9007199254740991
56
+
57
+
58
+ def github_channel(
59
+ *,
60
+ on_event: GitHubHandlers | None = None,
61
+ app: GitHubAppOptions | None = None,
62
+ multitask_strategy: MultitaskStrategy = "enqueue",
63
+ ) -> HttpChannel:
64
+ """Options for ``channels.github(...)``: App scope and event handlers that start runs.
65
+
66
+ Runs for the same issue or pull request share one thread.
67
+
68
+ Args:
69
+ on_event: Event handlers that start runs. Return a prompt (string,
70
+ message, or state), or ``None`` to skip. While a thread waits on an
71
+ interrupt, events start nothing and the issue or pull request is
72
+ told to continue it in LangSmith Studio.
73
+ app: App ``mda deploy`` creates; permissions and repository allowlist.
74
+ multitask_strategy: What an event that starts a run does while its
75
+ conversation already runs: ``enqueue`` (the default) waits for the
76
+ run and starts after it, ``interrupt`` cancels the run, keeping its
77
+ progress, and starts at once, ``rollback`` cancels it and discards
78
+ its progress, and ``reject`` starts nothing. Events on a
79
+ conversation that waits on an interrupt start nothing either way.
80
+
81
+ Example::
82
+
83
+ channels.github(
84
+ on_event={
85
+ "issues.opened": lambda e, _: f"Help with #{e.payload['issue']['number']}",
86
+ },
87
+ )
88
+ """
89
+ handlers = cast("Mapping[SupportedEventKey, GitHubEventCallback]", dict(on_event or {}))
90
+ if set(handlers) - SUPPORTED_EVENT_KEYS:
91
+ raise ValueError("unsupported GitHub event key")
92
+ repositories = _repositories(app)
93
+ provider_config: dict[str, JsonValue] = {"events": _subscribed_events(handlers)}
94
+ if app is not None:
95
+ provider_config["app"] = _app_config(app)
96
+
97
+ def name() -> str:
98
+ return channel.name or channel.kind
99
+
100
+ async def parse(ctx: HttpChannelRequest) -> HttpChannelParseResult:
101
+ return await _parse(ctx, handlers, name(), repositories)
102
+
103
+ async def run_input(
104
+ _message: HttpChannelInboundMessage, raw_event: JsonValue | None
105
+ ) -> ChannelRunResult | None:
106
+ return await _handler_input(handlers, raw_event, name(), paused=False)
107
+
108
+ async def on_paused(target: JsonValue, run: HttpChannelRunInfo) -> None:
109
+ # Only for an event its handler would act on, so the handler's filters,
110
+ # such as trusted senders or mentions, decide who gets a notice. The
111
+ # handler knows it is only asked, so it can skip side effects.
112
+ if await _handler_input(handlers, run.get("raw_event"), name(), paused=True) is None:
113
+ return
114
+ await _post_paused_notice(target, run)
115
+
116
+ channel = http_channel(
117
+ provider="github",
118
+ verify=_verify,
119
+ parse=parse,
120
+ input=run_input,
121
+ on_paused=on_paused,
122
+ provider_config=provider_config,
123
+ multitask_strategy=multitask_strategy,
124
+ )
125
+ return channel
126
+
127
+
128
+ def _repositories(app: GitHubAppOptions | None) -> frozenset[str] | None:
129
+ """The repositories the channel is limited to, after checking the App options."""
130
+ # A misspelled option would silently widen what the agent's token reaches.
131
+ unknown = sorted(set(app or {}) - {"permissions", "repositories"})
132
+ if unknown:
133
+ raise ValueError(
134
+ f"unknown GitHub app option {', '.join(unknown)}; use permissions or repositories"
135
+ )
136
+ # GitHub names are case-insensitive; deploy validates the rest.
137
+ repositories = (
138
+ None
139
+ if app is None or "repositories" not in app
140
+ else frozenset(repository.strip().lower() for repository in app["repositories"])
141
+ )
142
+ if repositories is not None and not repositories:
143
+ raise ValueError(
144
+ "GitHub app.repositories must list at least one repository; "
145
+ "omit it to allow every repository"
146
+ )
147
+ return repositories
148
+
149
+
150
+ def _subscribed_events(handlers: Mapping[SupportedEventKey, GitHubEventCallback]) -> list[str]:
151
+ """The events deploy subscribes the App to: the handled ones."""
152
+ return sorted(handlers)
153
+
154
+
155
+ def _app_config(app: GitHubAppOptions) -> dict[str, JsonValue]:
156
+ """The App options deploy reads, without unset fields."""
157
+ config: dict[str, JsonValue] = {}
158
+ if "permissions" in app:
159
+ config["permissions"] = dict(app["permissions"])
160
+ if "repositories" in app:
161
+ config["repositories"] = list(app["repositories"])
162
+ return config
163
+
164
+
165
+ # How long a value read from Agent Auth is reused before it is read again.
166
+ _APP_VALUE_TTL_SECONDS = 300.0
167
+ # How long a missing value is remembered, so unsigned traffic cannot hammer Agent Auth.
168
+ _MISSING_APP_VALUE_TTL_SECONDS = 60.0
169
+
170
+
171
+ class _AppValue:
172
+ __slots__ = ("expires_at", "value")
173
+
174
+ def __init__(self, value: asyncio.Future[str | None], expires_at: float) -> None:
175
+ self.value = value
176
+ self.expires_at = expires_at
177
+
178
+
179
+ _app_values: dict[str, _AppValue] = {}
180
+
181
+
182
+ async def _github_app_value(env_key: str, suffix: str) -> str | None:
183
+ """The App's webhook secret or bot ID.
184
+
185
+ The ``GH_*`` env override wins; otherwise the agent secret Agent Auth stored
186
+ when ``mda deploy`` created the App, at ``mda/github-app/{deployment}/{suffix}``.
187
+ Only a missing connection reads as ``None``; credential store failures propagate.
188
+ """
189
+ from managed_deepagents._connections import DEPLOYMENT_ID_ENV # noqa: PLC0415
190
+
191
+ override = os.environ.get(env_key, "").strip()
192
+ if override:
193
+ return override
194
+ deployment_id = os.environ.get(DEPLOYMENT_ID_ENV, "").strip()
195
+ if not deployment_id:
196
+ return None
197
+ slug = github_app_connection_slug(deployment_id, suffix)
198
+ entry = _app_values.get(slug)
199
+ if entry is None or entry.expires_at <= time.monotonic():
200
+ # Concurrent deliveries share the in-flight read.
201
+ entry = _AppValue(
202
+ asyncio.ensure_future(_read_app_value(slug, suffix)),
203
+ time.monotonic() + _APP_VALUE_TTL_SECONDS,
204
+ )
205
+ _app_values[slug] = entry
206
+ try:
207
+ # Shielded so one cancelled delivery does not cancel the shared read.
208
+ value = await asyncio.shield(entry.value)
209
+ except Exception:
210
+ # Read again on the next delivery rather than caching a failure.
211
+ if _app_values.get(slug) is entry:
212
+ del _app_values[slug]
213
+ raise
214
+ if value is None:
215
+ entry.expires_at = min(entry.expires_at, time.monotonic() + _MISSING_APP_VALUE_TTL_SECONDS)
216
+ return value
217
+
218
+
219
+ async def _read_app_value(slug: str, suffix: str) -> str | None:
220
+ from managed_deepagents._connections import ( # noqa: PLC0415
221
+ ConnectionNotFoundError,
222
+ connections,
223
+ )
224
+
225
+ try:
226
+ return await connections.resolve_deployment_secret(slug)
227
+ except ConnectionNotFoundError:
228
+ logger.error("GitHub App %s is not stored in Agent Auth", suffix)
229
+ return None
230
+
231
+
232
+ def reset_github_app_values() -> None:
233
+ """Forget values read from Agent Auth; for tests."""
234
+ _app_values.clear()
235
+
236
+
237
+ async def _verify(ctx: HttpChannelRequest) -> bool:
238
+ # GitHub pings a new App before Agent Auth has its webhook secret, and a
239
+ # ping starts nothing, so it needs no secret. Reading it here would also
240
+ # remember the secret as missing and reject the first real events.
241
+ if ctx.request.headers.get("x-github-event") == "ping":
242
+ return True
243
+ # Reject unsigned deliveries before reading the secret from Agent Auth.
244
+ signature = ctx.request.headers.get("x-hub-signature-256", "")
245
+ if re.fullmatch(r"sha256=[a-fA-F0-9]{64}", signature) is None:
246
+ return False
247
+ secret = await _github_app_value("GH_WEBHOOK_SECRET", "webhook-secret")
248
+ if not secret:
249
+ return False
250
+ expected = hmac.digest(secret.encode(), ctx.raw_body, "sha256")
251
+ return hmac.compare_digest(expected, bytes.fromhex(signature[7:]))
252
+
253
+
254
+ async def _parse(
255
+ ctx: HttpChannelRequest,
256
+ handlers: Mapping[SupportedEventKey, GitHubEventCallback],
257
+ channel_name: str,
258
+ repositories: frozenset[str] | None,
259
+ ) -> HttpChannelParseResult:
260
+ json_response = import_module("starlette.responses").JSONResponse
261
+
262
+ envelope = github_receipt_adapter.validate_python(
263
+ {
264
+ "event": ctx.request.headers.get("x-github-event"),
265
+ "delivery_id": ctx.request.headers.get("x-github-delivery"),
266
+ "payload": await ctx.request.json(),
267
+ },
268
+ strict=True,
269
+ )
270
+ event = envelope["event"]
271
+ record = envelope["payload"]
272
+ _receipt(record, event, envelope["delivery_id"])
273
+ response = json_response({"status": "received"}, status_code=200 if event == "ping" else 202)
274
+
275
+ # Events nothing handles stop here, before run creation.
276
+ # Events without an action, such as `push`, are keyed by their name.
277
+ action = record.get("action")
278
+ key = cast("SupportedEventKey", event if action is None else f"{event}.{action}")
279
+ if handlers.get(key) is None:
280
+ return {"type": "ignore", "response": response}
281
+
282
+ parsed_event = github_event(key, record, envelope["delivery_id"])
283
+
284
+ # Events from repositories the channel is not limited to start nothing.
285
+ if repositories is not None and parsed_event.repository.full_name.lower() not in repositories:
286
+ return {"type": "ignore", "response": response}
287
+
288
+ # Ignore the App's own activity to avoid triggering ourselves.
289
+ configured_bot_id = await _github_app_value("GH_APP_BOT_ID", "bot-id") or ""
290
+ if not re.fullmatch(r"[1-9][0-9]*", configured_bot_id):
291
+ raise TypeError(
292
+ "GitHub App bot ID is unavailable; run `mda deploy` to store it or set GH_APP_BOT_ID"
293
+ )
294
+ bot_id = github_identifier_adapter.validate_python(int(configured_bot_id), strict=True)
295
+ if parsed_event.sender.id == bot_id:
296
+ return {"type": "ignore", "response": response}
297
+
298
+ # Preserve the original event even if a callback modifies its argument.
299
+ event_snapshot = asdict(parsed_event)
300
+ if not is_json_value(event_snapshot):
301
+ raise TypeError("invalid GitHub event")
302
+
303
+ # One thread per issue or pull request, so the agent keeps the
304
+ # conversation. Other events get their own thread, keyed by the delivery so
305
+ # a redelivery still finds the run it started.
306
+ delivery_id = envelope["delivery_id"]
307
+ number = _conversation_number(parsed_event)
308
+ thread_id = channel_thread_id(
309
+ channel_name,
310
+ f"delivery/{delivery_id}" if number is None else f"{parsed_event.repository.id}/{number}",
311
+ )
312
+ target: dict[str, JsonValue] | None = None
313
+ if number is not None:
314
+ target = {"repository": parsed_event.repository.full_name, "number": number}
315
+ # What the run starts with is decided once MDA knows the thread takes a new
316
+ # turn.
317
+ message: HttpChannelInboundMessage = {
318
+ "thread_id": thread_id,
319
+ "user_id": "mda:channel",
320
+ "target": target,
321
+ "delivery_key": delivery_id,
322
+ }
323
+ return {
324
+ "type": "message",
325
+ "message": message,
326
+ "raw_event": event_snapshot,
327
+ "response": response,
328
+ }
329
+
330
+
331
+ async def _handler_input(
332
+ handlers: Mapping[SupportedEventKey, GitHubEventCallback],
333
+ raw_event: JsonValue | None,
334
+ channel_name: str,
335
+ *,
336
+ paused: bool,
337
+ ) -> ChannelRunResult | None:
338
+ """What the event's handler starts a run with; ``None`` when it skips or fails."""
339
+ event = _event(raw_event)
340
+ handler = None if event is None else handlers.get(event.key)
341
+ if event is None or handler is None:
342
+ return None
343
+ receipt = {
344
+ "event": logger.log_identifier(event.key),
345
+ "deliveryId": logger.log_identifier(event.delivery_id),
346
+ }
347
+ try:
348
+ result = handler(
349
+ event, GitHubChannelRuntime(channel=_ChannelName(name=channel_name), paused=paused)
350
+ )
351
+ content = await result if inspect.isawaitable(result) else result
352
+ except Exception as error: # noqa: BLE001
353
+ logger.error(
354
+ "github callback failed %s (%s)",
355
+ json.dumps(receipt),
356
+ logger.safe_error_label(error),
357
+ )
358
+ return None
359
+ if content is not None:
360
+ logger.info("GitHub handler returned run input %s", json.dumps(receipt))
361
+ return cast("ChannelRunResult | None", content)
362
+
363
+
364
+ def _event(raw_event: JsonValue | None) -> GitHubEvent | None:
365
+ """The parsed event a delivery carries, as callbacks may change it."""
366
+ if not isinstance(raw_event, Mapping):
367
+ return None
368
+ raw = cast("Mapping[str, object]", copy.deepcopy(dict(raw_event)))
369
+ try:
370
+ return GitHubEvent(
371
+ key=cast("SupportedEventKey", raw["key"]),
372
+ name=cast("str", raw["name"]),
373
+ action=cast("str", raw["action"]),
374
+ delivery_id=cast("str", raw["delivery_id"]),
375
+ installation_id=cast("int", raw["installation_id"]),
376
+ repository=GitHubRepository(**cast("dict[str, Any]", raw["repository"])),
377
+ sender=GitHubSender(**cast("dict[str, Any]", raw["sender"])),
378
+ payload=cast("Any", raw["payload"]),
379
+ )
380
+ except (KeyError, TypeError):
381
+ return None
382
+
383
+
384
+ def _conversation_number(event: GitHubEvent) -> int | None:
385
+ """The issue or pull request an event is about, if any."""
386
+ payload = cast("Mapping[str, object]", event.payload)
387
+ for subject in ("issue", "pull_request"):
388
+ record = payload.get(subject)
389
+ number = record.get("number") if isinstance(record, Mapping) else None
390
+ if _is_issue_number(number):
391
+ return cast("int", number)
392
+ # A CI run belongs to a pull request only when it ran for exactly one.
393
+ for kind in ("workflow_run", "check_run", "check_suite"):
394
+ run = payload.get(kind)
395
+ pull_requests = run.get("pull_requests") if isinstance(run, Mapping) else None
396
+ if isinstance(pull_requests, list) and len(pull_requests) == 1:
397
+ only = pull_requests[0]
398
+ number = only.get("number") if isinstance(only, Mapping) else None
399
+ if _is_issue_number(number):
400
+ return cast("int", number)
401
+ return None
402
+
403
+
404
+ def _is_issue_number(value: object) -> bool:
405
+ return type(value) is int and 0 < value <= _MAX_SAFE_INTEGER
406
+
407
+
408
+ class _GitHubTarget(TypedDict):
409
+ """Where notices go: the issue or pull request of the conversation."""
410
+
411
+ repository: str
412
+ number: int
413
+
414
+
415
+ _REPOSITORY_PATTERN = re.compile(r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+")
416
+ _UUID_PATTERN = re.compile(
417
+ r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE
418
+ )
419
+ _GITHUB_API_TIMEOUT_SECONDS = 10.0
420
+
421
+
422
+ async def _post_paused_notice(target: JsonValue, run: HttpChannelRunInfo) -> None:
423
+ """The thread waits on an interrupt, so tell the issue or pull request where to continue it.
424
+
425
+ Posts as the App with the agent's installation token; the notice is fixed text.
426
+ """
427
+ if not _is_github_target(target):
428
+ return
429
+ from managed_deepagents._connections import connections # noqa: PLC0415
430
+
431
+ url = studio_url(run)
432
+ studio = f"[LangSmith Studio]({url})" if url else "LangSmith Studio"
433
+ github_target = cast("_GitHubTarget", target)
434
+ owner, name = github_target["repository"].split("/")
435
+ token = await connections.resolve_deployment_secret(GITHUB_TOKEN_SLUG)
436
+ async with httpx.AsyncClient(
437
+ timeout=_GITHUB_API_TIMEOUT_SECONDS, follow_redirects=False
438
+ ) as client:
439
+ response = await client.post(
440
+ f"https://api.github.com/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
441
+ f"/issues/{github_target['number']}/comments",
442
+ headers={
443
+ "Accept": "application/vnd.github+json",
444
+ "Authorization": f"Bearer {token}",
445
+ "X-GitHub-Api-Version": "2022-11-28",
446
+ },
447
+ json={
448
+ "body": "I'm waiting for a decision on an earlier step in this conversation, "
449
+ f"so I haven't read this yet. Review the pending step in {studio}, "
450
+ "then comment again."
451
+ },
452
+ )
453
+ if not response.is_success:
454
+ raise RuntimeError(f"GitHub refused the paused notice (HTTP {response.status_code})")
455
+
456
+
457
+ _DEPLOYMENT_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
458
+
459
+
460
+ def studio_url(run: HttpChannelRunInfo | None, env: Mapping[str, str] | None = None) -> str | None:
461
+ """The thread in the deployment's Studio.
462
+
463
+ The deployment's page when the deployment's name or the thread's assistant
464
+ is unknown. The LangSmith UI is the one ``mda deploy`` recorded for the
465
+ deployment's environment (``MDA_LANGSMITH_URL``); without it there is
466
+ nothing to link to.
467
+ """
468
+ from managed_deepagents._connections import DEPLOYMENT_ID_ENV # noqa: PLC0415
469
+ from managed_deepagents.runtime import _managed_deployment_name # noqa: PLC0415
470
+
471
+ values = os.environ if env is None else env
472
+ workspace = values.get("LANGSMITH_TENANT_ID", "").strip()
473
+ deployment = values.get(DEPLOYMENT_ID_ENV, "").strip()
474
+ if not (_UUID_PATTERN.fullmatch(workspace) and _UUID_PATTERN.fullmatch(deployment)):
475
+ return None
476
+ origin = _http_origin(values.get("MDA_LANGSMITH_URL", ""))
477
+ if origin is None:
478
+ return None
479
+ name = _managed_deployment_name(values) or ""
480
+ thread_id = (run or {}).get("thread_id", "")
481
+ assistant_id = (run or {}).get("assistant_id", "")
482
+ if not (
483
+ _DEPLOYMENT_NAME_PATTERN.fullmatch(name)
484
+ and _UUID_PATTERN.fullmatch(thread_id)
485
+ and _UUID_PATTERN.fullmatch(assistant_id)
486
+ ):
487
+ return f"{origin}/o/{workspace}/host/deployments/{deployment}"
488
+ query = urlencode(
489
+ {
490
+ "organizationId": workspace,
491
+ "render": "interact",
492
+ "hostProjectId": deployment,
493
+ "assistantId": assistant_id,
494
+ "mode": "chat",
495
+ "threadId": thread_id,
496
+ }
497
+ )
498
+ return f"{origin}/o/{workspace}/a/{quote(name, safe='')}/studio/thread?{query}"
499
+
500
+
501
+ def _http_origin(value: str) -> str | None:
502
+ url = urlsplit(value.strip())
503
+ if url.scheme not in ("http", "https") or not url.hostname:
504
+ return None
505
+ return f"{url.scheme}://{url.netloc.rsplit('@', 1)[-1]}"
506
+
507
+
508
+ def _is_github_target(value: object) -> bool:
509
+ if not isinstance(value, Mapping):
510
+ return False
511
+ repository = value.get("repository")
512
+ return (
513
+ isinstance(repository, str)
514
+ and _REPOSITORY_PATTERN.fullmatch(repository) is not None
515
+ and _is_issue_number(value.get("number"))
516
+ )
517
+
518
+
519
+ def _receipt(record: Mapping[str, object], event: str, delivery_id: str) -> dict[str, str | int]:
520
+ receipt: dict[str, str | int] = {
521
+ "event": logger.log_identifier(event),
522
+ "deliveryId": logger.log_identifier(delivery_id),
523
+ }
524
+ action = record.get("action")
525
+ if isinstance(action, str):
526
+ receipt["action"] = logger.log_identifier(action)
527
+ for key, field in (("repository", "id"), ("issue", "number"), ("pull_request", "number")):
528
+ value = record.get(key)
529
+ if isinstance(value, dict):
530
+ receipt_id = value.get(field)
531
+ if type(receipt_id) is int and abs(receipt_id) <= _MAX_SAFE_INTEGER:
532
+ receipt[key] = receipt_id
533
+ logger.info("github receipt %s", json.dumps(receipt))
534
+ return receipt