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.
- managed_deepagents/__init__.py +32 -0
- managed_deepagents/_agent_auth.py +42 -20
- managed_deepagents/_binaries/mda.exe +0 -0
- managed_deepagents/_channels/github.py +534 -0
- managed_deepagents/_channels/github_connections.py +15 -0
- managed_deepagents/_channels/github_events.py +28 -0
- managed_deepagents/_channels/github_schema.py +279 -0
- managed_deepagents/_channels/github_types.py +173 -0
- managed_deepagents/_channels/http/__init__.py +39 -1
- managed_deepagents/_channels/http/http.py +175 -34
- managed_deepagents/_channels/http/ingress.py +153 -13
- managed_deepagents/_channels/http/threads.py +16 -0
- managed_deepagents/_channels/http/types.py +32 -1
- managed_deepagents/_channels/reserved_connections.py +42 -0
- managed_deepagents/_channels/run_input.py +127 -0
- managed_deepagents/_channels/trigger/ingress.py +2 -15
- managed_deepagents/_connections.py +61 -1
- managed_deepagents/_credential_gate.py +35 -2
- managed_deepagents/_identity_auth.py +112 -11
- managed_deepagents/_identity_runtime.py +24 -0
- managed_deepagents/_ingress_runs.py +153 -10
- managed_deepagents/_runtime_schedules.py +10 -7
- managed_deepagents/_schedule_auth.py +1 -55
- managed_deepagents/namespaces.py +2 -0
- managed_deepagents/runtime.py +4 -3
- {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/METADATA +168 -4
- {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/RECORD +31 -23
- {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/WHEEL +0 -0
- {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/entry_points.txt +0 -0
- {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {managed_deepagents-0.9.3.dev4.dist-info → managed_deepagents-0.10.0.dev2.dist-info}/top_level.txt +0 -0
managed_deepagents/__init__.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|