langgraph-api 0.18.0.dev3__py3-none-any.whl → 0.18.0.dev4__py3-none-any.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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/event_streaming.py +71 -7
- langgraph_api/config/__init__.py +2 -4
- langgraph_api/drain.py +64 -2
- langgraph_api/event_streaming/service.py +75 -129
- langgraph_api/event_streaming/session.py +11 -6
- langgraph_api/grpc/ops/threads.py +54 -6
- langgraph_api/models/run.py +7 -0
- langgraph_api/utils/headers.py +5 -0
- langgraph_api/validation.py +6 -6
- {langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/METADATA +1 -1
- {langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/RECORD +16 -16
- openapi.json +20 -0
- {langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/WHEEL +0 -0
- {langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/licenses/LICENSE +0 -0
langgraph_api/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.18.0.
|
|
1
|
+
__version__ = "0.18.0.dev4"
|
|
@@ -28,6 +28,7 @@ from uuid import uuid4
|
|
|
28
28
|
|
|
29
29
|
import orjson
|
|
30
30
|
import structlog
|
|
31
|
+
from starlette.exceptions import HTTPException
|
|
31
32
|
from starlette.responses import Response
|
|
32
33
|
from starlette.websockets import WebSocketDisconnect
|
|
33
34
|
|
|
@@ -38,6 +39,7 @@ from langgraph_api.feature_flags import IS_POSTGRES_OR_GRPC_BACKEND
|
|
|
38
39
|
from langgraph_api.route import ApiRoute, ApiWebSocketRoute
|
|
39
40
|
from langgraph_api.serde import json_dumpb
|
|
40
41
|
from langgraph_api.sse import EventSourceResponse
|
|
42
|
+
from langgraph_api.utils import validate_uuid
|
|
41
43
|
|
|
42
44
|
if TYPE_CHECKING:
|
|
43
45
|
from collections.abc import Mapping
|
|
@@ -98,6 +100,7 @@ def _make_manager(
|
|
|
98
100
|
send_event: Any = None,
|
|
99
101
|
*,
|
|
100
102
|
headers: Mapping[str, str] | None = None,
|
|
103
|
+
replay_buffer: bool = True,
|
|
101
104
|
) -> ThreadRunManager:
|
|
102
105
|
# Auth context is propagated via ``ApiRoute``'s ``with_user`` wrapper
|
|
103
106
|
# — ops reads it off the context var, so we don't thread it through
|
|
@@ -108,6 +111,7 @@ def _make_manager(
|
|
|
108
111
|
threads=Threads,
|
|
109
112
|
send_event=send_event,
|
|
110
113
|
headers=headers,
|
|
114
|
+
replay_buffer=replay_buffer,
|
|
111
115
|
)
|
|
112
116
|
|
|
113
117
|
|
|
@@ -143,8 +147,12 @@ async def _thread_events(request: Request) -> Response:
|
|
|
143
147
|
If reading the thread's event stream fails, the server ends the
|
|
144
148
|
stream with a final ``event: error`` frame carrying a protocol error
|
|
145
149
|
(``"type": "error"``, ``"id": null``, ``"error": "unknown_error"``).
|
|
150
|
+
|
|
151
|
+
A thread the caller isn't allowed to read returns 403 or 404 before
|
|
152
|
+
the stream starts, and a malformed thread ID returns 422.
|
|
146
153
|
"""
|
|
147
154
|
thread_id = request.path_params["thread_id"]
|
|
155
|
+
thread_uuid = validate_uuid(thread_id, "Invalid thread ID: must be a UUID")
|
|
148
156
|
try:
|
|
149
157
|
body = orjson.loads(await request.body())
|
|
150
158
|
except Exception:
|
|
@@ -217,6 +225,10 @@ async def _thread_events(request: Request) -> Response:
|
|
|
217
225
|
else None
|
|
218
226
|
)
|
|
219
227
|
|
|
228
|
+
# Checked here, before the 200, because clients retry a 200 that ends in
|
|
229
|
+
# an error instead of giving up.
|
|
230
|
+
auth_filters = await Threads.Stream.check_thread_stream_auth(thread_uuid)
|
|
231
|
+
|
|
220
232
|
# Bounded LRU dedup: ``recent_eids`` is the eviction order, ``delivered``
|
|
221
233
|
# is the O(1) membership view. When the deque hits its cap, the oldest
|
|
222
234
|
# entry rolls off; we mirror that on ``delivered`` to keep them in sync.
|
|
@@ -241,7 +253,10 @@ async def _thread_events(request: Request) -> Response:
|
|
|
241
253
|
pending_events.append(event)
|
|
242
254
|
flush_pending.set()
|
|
243
255
|
|
|
244
|
-
|
|
256
|
+
# SSE never replays from the session's buffer: it has no subscribe or
|
|
257
|
+
# reconnect commands, and ``start_thread_stream`` replays from the
|
|
258
|
+
# thread's event stream.
|
|
259
|
+
manager = _make_manager(thread_id, send_event=on_event, replay_buffer=False)
|
|
245
260
|
|
|
246
261
|
filter_sub = Subscription(
|
|
247
262
|
id=str(uuid4()),
|
|
@@ -264,7 +279,7 @@ async def _thread_events(request: Request) -> Response:
|
|
|
264
279
|
# outbound events; the thread-level reader always replays from the
|
|
265
280
|
# beginning so namespace-scoped projections see history. See
|
|
266
281
|
# ``ThreadRunManager.start_thread_stream`` for the rationale.
|
|
267
|
-
manager.start_thread_stream()
|
|
282
|
+
manager.start_thread_stream(auth_filters=auth_filters)
|
|
268
283
|
|
|
269
284
|
async def body_iter():
|
|
270
285
|
try:
|
|
@@ -377,20 +392,51 @@ async def _thread_command(request: Request) -> Response:
|
|
|
377
392
|
# ---------------------------------------------------------------------------
|
|
378
393
|
|
|
379
394
|
|
|
395
|
+
async def _close_for_failed_access_check(websocket: WebSocket, exc: Exception) -> None:
|
|
396
|
+
if isinstance(exc, HTTPException) and exc.status_code < 500:
|
|
397
|
+
await websocket.close(code=4000 + exc.status_code)
|
|
398
|
+
return
|
|
399
|
+
logger.exception("Thread stream access check failed")
|
|
400
|
+
await websocket.send_text(_THREAD_STREAM_FAILED_DATA.decode())
|
|
401
|
+
await websocket.close(code=1011, reason="thread stream ended")
|
|
402
|
+
|
|
403
|
+
|
|
380
404
|
async def _thread_websocket(websocket: WebSocket) -> None:
|
|
381
405
|
"""Full-duplex protocol connection scoped to a thread.
|
|
382
406
|
|
|
383
407
|
Supports ``run.start``, ``input.respond``, ``subscription.subscribe``,
|
|
384
408
|
``subscription.unsubscribe``, ``agent.getTree``.
|
|
409
|
+
|
|
410
|
+
A 4xx from the thread's access check closes the socket with 4000 plus the
|
|
411
|
+
status (4403, 4422). A 404 can be a thread the client minted and has yet
|
|
412
|
+
to create, so the socket then waits for a ``run.start``, which checks
|
|
413
|
+
access like any run start, and closes with 4404 if another command comes
|
|
414
|
+
first. Any other failure of the check, a 5xx included, gets the stream
|
|
415
|
+
error frame and a 1011 close, as when the stream fails later.
|
|
385
416
|
"""
|
|
386
417
|
thread_id = websocket.path_params["thread_id"]
|
|
418
|
+
# Browsers report a refused upgrade only as a 1006 close, so accept first
|
|
419
|
+
# to make the close code readable.
|
|
387
420
|
await websocket.accept()
|
|
421
|
+
try:
|
|
422
|
+
thread_uuid = validate_uuid(thread_id, "Invalid thread ID: must be a UUID")
|
|
423
|
+
except HTTPException as exc:
|
|
424
|
+
await _close_for_failed_access_check(websocket, exc)
|
|
425
|
+
return
|
|
426
|
+
awaiting_thread = False
|
|
427
|
+
auth_filters: Any = None
|
|
428
|
+
try:
|
|
429
|
+
auth_filters = await Threads.Stream.check_thread_stream_auth(thread_uuid)
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
if not (isinstance(exc, HTTPException) and exc.status_code == 404):
|
|
432
|
+
await _close_for_failed_access_check(websocket, exc)
|
|
433
|
+
return
|
|
434
|
+
awaiting_thread = True
|
|
388
435
|
|
|
389
436
|
async def ws_send_event(event: dict[str, Any]) -> None:
|
|
390
437
|
await websocket.send_text(orjson.dumps(event).decode("utf-8"))
|
|
391
438
|
|
|
392
439
|
manager = _make_manager(thread_id, send_event=ws_send_event)
|
|
393
|
-
manager.start_thread_stream()
|
|
394
440
|
|
|
395
441
|
# Watchdog: when the thread-stream consumer stops, send the error frame
|
|
396
442
|
# (if it wasn't cancelled) and close the socket, instead of leaving the
|
|
@@ -405,7 +451,11 @@ async def _thread_websocket(websocket: WebSocket) -> None:
|
|
|
405
451
|
with contextlib.suppress(Exception):
|
|
406
452
|
await websocket.close(code=1011, reason="thread stream ended")
|
|
407
453
|
|
|
408
|
-
|
|
454
|
+
def start_stream(filters: Any) -> asyncio.Task[None]:
|
|
455
|
+
manager.start_thread_stream(auth_filters=filters)
|
|
456
|
+
return asyncio.create_task(_stream_watchdog())
|
|
457
|
+
|
|
458
|
+
watchdog = None if awaiting_thread else start_stream(auth_filters)
|
|
409
459
|
|
|
410
460
|
try:
|
|
411
461
|
while True:
|
|
@@ -438,16 +488,30 @@ async def _thread_websocket(websocket: WebSocket) -> None:
|
|
|
438
488
|
)
|
|
439
489
|
continue
|
|
440
490
|
|
|
491
|
+
if awaiting_thread and payload["method"] != "run.start":
|
|
492
|
+
await websocket.close(code=4404)
|
|
493
|
+
return
|
|
441
494
|
response = await manager.handle_command(payload)
|
|
442
495
|
await websocket.send_text(orjson.dumps(response).decode("utf-8"))
|
|
496
|
+
if awaiting_thread and response.get("type") == "success":
|
|
497
|
+
try:
|
|
498
|
+
auth_filters = await Threads.Stream.check_thread_stream_auth(
|
|
499
|
+
thread_uuid
|
|
500
|
+
)
|
|
501
|
+
except Exception as exc:
|
|
502
|
+
await _close_for_failed_access_check(websocket, exc)
|
|
503
|
+
return
|
|
504
|
+
watchdog = start_stream(auth_filters)
|
|
505
|
+
awaiting_thread = False
|
|
443
506
|
except WebSocketDisconnect:
|
|
444
507
|
pass
|
|
445
508
|
except Exception:
|
|
446
509
|
logger.exception("Protocol WebSocket error")
|
|
447
510
|
finally:
|
|
448
|
-
watchdog
|
|
449
|
-
|
|
450
|
-
|
|
511
|
+
if watchdog is not None:
|
|
512
|
+
watchdog.cancel()
|
|
513
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
514
|
+
await watchdog
|
|
451
515
|
await manager.close()
|
|
452
516
|
|
|
453
517
|
|
langgraph_api/config/__init__.py
CHANGED
|
@@ -507,7 +507,8 @@ FF_CRONS_ENABLED = env("FF_CRONS_ENABLED", cast=bool, default=True)
|
|
|
507
507
|
FF_LOG_DROPPED_EVENTS = env("FF_LOG_DROPPED_EVENTS", cast=bool, default=False)
|
|
508
508
|
FF_LOG_QUERY_AND_PARAMS = env("FF_LOG_QUERY_AND_PARAMS", cast=bool, default=False)
|
|
509
509
|
# Opt-in handling of drained runs: the server's control signalling to the
|
|
510
|
-
# graph and its handling of drained runs, together.
|
|
510
|
+
# graph and its handling of drained runs, together. On, a Postgres-backed
|
|
511
|
+
# replica that is shutting down also drains its in-flight runs.
|
|
511
512
|
# Off: no control is created or seeded, and GraphDrained lands in the
|
|
512
513
|
# catch-all error branch.
|
|
513
514
|
FF_RESUME_DRAINED_RUNS = env("FF_RESUME_DRAINED_RUNS", cast=bool, default=False)
|
|
@@ -535,8 +536,6 @@ RESUME_DRAINED_RUNS = _resolve_resume_drained_runs(
|
|
|
535
536
|
is_postgres=IS_POSTGRES_BACKEND,
|
|
536
537
|
langgraph_supported=DRAIN_RESUME_SUPPORT,
|
|
537
538
|
)
|
|
538
|
-
# Not in public docs: internal feature flag
|
|
539
|
-
FF_USE_REDIS_QUEUE = env("FF_USE_REDIS_QUEUE", cast=bool, default=True)
|
|
540
539
|
|
|
541
540
|
|
|
542
541
|
# Internal flag intended for testing only
|
|
@@ -811,7 +810,6 @@ __all__ = [
|
|
|
811
810
|
"FF_PYSPY_PROFILING_ENABLED",
|
|
812
811
|
"FF_PYSPY_PROFILING_MAX_DURATION_SECS",
|
|
813
812
|
"FF_RESUME_DRAINED_RUNS",
|
|
814
|
-
"FF_USE_REDIS_QUEUE",
|
|
815
813
|
"GRPC_CLIENT_HTTP2_INITIAL_WINDOW_SIZE",
|
|
816
814
|
"GRPC_CLIENT_MAX_RECV_MSG_BYTES",
|
|
817
815
|
"GRPC_CLIENT_MAX_SEND_MSG_BYTES",
|
langgraph_api/drain.py
CHANGED
|
@@ -10,6 +10,7 @@ guarded and the placeholders below never match or instantiate anything.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import contextvars
|
|
13
|
+
import threading
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
# The control of the run whose top-level graph this context is executing. The
|
|
@@ -47,17 +48,35 @@ try:
|
|
|
47
48
|
The request is visible only where this is provably the run's top-level
|
|
48
49
|
loop: in a context the server marked for this control (see
|
|
49
50
|
mark_top_level) and outside any graph node. So the run drains only at
|
|
50
|
-
a top-level step boundary, after that step's subgraphs have finished
|
|
51
|
+
a top-level step boundary, after that step's subgraphs have finished,
|
|
52
|
+
and never before its first step.
|
|
51
53
|
Graph code inside a node, and threads a node starts, see no drain
|
|
52
54
|
(drain_requested False, drain_reason None).
|
|
53
55
|
"""
|
|
54
56
|
|
|
57
|
+
__slots__ = ("_first_check_done",)
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self._first_check_done = False
|
|
62
|
+
|
|
55
63
|
def _visible(self) -> bool:
|
|
56
64
|
return _TOP_LEVEL_CONTROL.get() is self and not _in_graph_node()
|
|
57
65
|
|
|
58
66
|
@property
|
|
59
67
|
def drain_requested(self) -> bool:
|
|
60
|
-
|
|
68
|
+
if not self._visible():
|
|
69
|
+
return False
|
|
70
|
+
# LangGraph checks once per top-level step boundary, before the
|
|
71
|
+
# step. Only a server drain can be pending at the first check. A
|
|
72
|
+
# run started with a Command has no checkpoint of its own until its
|
|
73
|
+
# first step ends, so if it drained there, its next claim would
|
|
74
|
+
# re-send the Command and LangGraph would apply it again. So a run
|
|
75
|
+
# always completes a step before it drains.
|
|
76
|
+
if not self._first_check_done:
|
|
77
|
+
self._first_check_done = True
|
|
78
|
+
return False
|
|
79
|
+
return RunControl.drain_requested.fget(self)
|
|
61
80
|
|
|
62
81
|
@property
|
|
63
82
|
def drain_reason(self) -> str | None:
|
|
@@ -79,6 +98,11 @@ except ImportError:
|
|
|
79
98
|
|
|
80
99
|
DRAIN_SUPPORT = False
|
|
81
100
|
|
|
101
|
+
# Reason the server passes to request_drain() when its pod is shutting down.
|
|
102
|
+
# Graph code can pass any reason (request_drain() defaults to "shutdown"), so
|
|
103
|
+
# this distinct value is what tells server drains from graph drains in logs.
|
|
104
|
+
SERVER_SHUTDOWN_DRAIN_REASON = "server_shutdown"
|
|
105
|
+
|
|
82
106
|
|
|
83
107
|
def mark_top_level(control: ServerRunControl | None) -> None:
|
|
84
108
|
"""Mark the current context as running ``control``'s top-level graph."""
|
|
@@ -92,10 +116,48 @@ def new_run_control() -> ServerRunControl | None:
|
|
|
92
116
|
return None
|
|
93
117
|
|
|
94
118
|
|
|
119
|
+
class RunControlRegistry:
|
|
120
|
+
"""Controls of the runs a queue drains at shutdown, keyed by run ID.
|
|
121
|
+
|
|
122
|
+
Workers can finish on background threads (isolated loops), so every access
|
|
123
|
+
takes a lock and ``drain_all`` works on a snapshot. Controls are registered
|
|
124
|
+
before the run starts, and removed only by the claim that registered them,
|
|
125
|
+
so a fast-finishing or superseded claim can't leave or remove the wrong
|
|
126
|
+
entry.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
self._lock = threading.Lock()
|
|
131
|
+
self._controls: dict[str, ServerRunControl] = {}
|
|
132
|
+
|
|
133
|
+
def add(self, run_id: Any, control: ServerRunControl) -> None:
|
|
134
|
+
"""Register a run's control before the run starts."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._controls[str(run_id)] = control
|
|
137
|
+
|
|
138
|
+
def release(self, run_id: Any, control: ServerRunControl | None) -> None:
|
|
139
|
+
"""Remove a run's control if it is still the one this claim registered."""
|
|
140
|
+
if control is None:
|
|
141
|
+
return
|
|
142
|
+
with self._lock:
|
|
143
|
+
if self._controls.get(str(run_id)) is control:
|
|
144
|
+
del self._controls[str(run_id)]
|
|
145
|
+
|
|
146
|
+
def drain_all(self, reason: str) -> int:
|
|
147
|
+
"""Request a drain from every registered run; return how many."""
|
|
148
|
+
with self._lock:
|
|
149
|
+
controls = list(self._controls.values())
|
|
150
|
+
for control in controls:
|
|
151
|
+
control.request_drain(reason)
|
|
152
|
+
return len(controls)
|
|
153
|
+
|
|
154
|
+
|
|
95
155
|
__all__ = [
|
|
96
156
|
"DRAIN_SUPPORT",
|
|
157
|
+
"SERVER_SHUTDOWN_DRAIN_REASON",
|
|
97
158
|
"GraphDrained",
|
|
98
159
|
"RunControl",
|
|
160
|
+
"RunControlRegistry",
|
|
99
161
|
"ServerRunControl",
|
|
100
162
|
"mark_top_level",
|
|
101
163
|
"new_run_control",
|
|
@@ -22,7 +22,6 @@ import asyncio
|
|
|
22
22
|
import contextlib
|
|
23
23
|
import inspect
|
|
24
24
|
import re
|
|
25
|
-
import time
|
|
26
25
|
from collections.abc import Awaitable, Callable, Mapping
|
|
27
26
|
from typing import Any, cast, get_args
|
|
28
27
|
from uuid import UUID, uuid4
|
|
@@ -46,6 +45,7 @@ from langgraph_api.event_streaming.session import (
|
|
|
46
45
|
from langgraph_api.event_streaming.types import Subscription
|
|
47
46
|
from langgraph_api.feature_flags import FF_V2_EVENT_STREAMING, PREFER_GRPC_CHECKPOINTER
|
|
48
47
|
from langgraph_api.schema import MultitaskStrategy
|
|
48
|
+
from langgraph_api.validation import sanitize_reserved_keys
|
|
49
49
|
|
|
50
50
|
logger = structlog.stdlib.get_logger(__name__)
|
|
51
51
|
|
|
@@ -145,10 +145,7 @@ def _multitask_strategy_from_run_start(params: dict[str, Any]) -> str:
|
|
|
145
145
|
EventSink = Callable[[dict[str, Any]], Awaitable[None] | None]
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
# ``set_joint_status`` commits interrupts after the stream event; gRPC can lag.
|
|
149
148
|
_IN_FLIGHT_THREAD_STATUS = "busy"
|
|
150
|
-
_INTERRUPT_SETTLE_TIMEOUT_SECONDS = 5.0
|
|
151
|
-
_INTERRUPT_SETTLE_POLL_INTERVAL_SECONDS = 0.05
|
|
152
149
|
# langgraph resumes interrupts by id only when every key of the resume map has
|
|
153
150
|
# this shape; any other key turns the whole map into one value for whichever
|
|
154
151
|
# interrupt is pending.
|
|
@@ -192,6 +189,9 @@ class ThreadRunManager:
|
|
|
192
189
|
One ``ThreadRunManager`` is created per connection (HTTP SSE stream or
|
|
193
190
|
WebSocket). It is not shared across connections — each observer owns
|
|
194
191
|
its own view of the thread's run stream.
|
|
192
|
+
|
|
193
|
+
``replay_buffer=False`` keeps no per-run replay buffer, for connections
|
|
194
|
+
that never replay from it.
|
|
195
195
|
"""
|
|
196
196
|
|
|
197
197
|
def __init__(
|
|
@@ -202,12 +202,14 @@ class ThreadRunManager:
|
|
|
202
202
|
threads: Any,
|
|
203
203
|
send_event: EventSink | None = None,
|
|
204
204
|
headers: Mapping[str, str] | None = None,
|
|
205
|
+
replay_buffer: bool = True,
|
|
205
206
|
) -> None:
|
|
206
207
|
self._thread_id = thread_id
|
|
207
208
|
self._runs = runs
|
|
208
209
|
self._threads = threads
|
|
209
210
|
self._send_event = send_event
|
|
210
211
|
self._headers = headers if headers is not None else {}
|
|
212
|
+
self._replay_buffer = replay_buffer
|
|
211
213
|
self._session: EventStreamingSession | None = None
|
|
212
214
|
self._current_run_id: str | None = None
|
|
213
215
|
self._queued_events: list[dict[str, Any]] = []
|
|
@@ -291,7 +293,7 @@ class ThreadRunManager:
|
|
|
291
293
|
active=True,
|
|
292
294
|
)
|
|
293
295
|
|
|
294
|
-
def start_thread_stream(self) -> None:
|
|
296
|
+
def start_thread_stream(self, *, auth_filters: Any = None) -> None:
|
|
295
297
|
"""Start consuming the thread-level stream for this connection.
|
|
296
298
|
|
|
297
299
|
Always start from the beginning of the thread's event stream
|
|
@@ -306,14 +308,19 @@ class ThreadRunManager:
|
|
|
306
308
|
produce output…". The SSE/WS sink applies the caller's ``since``
|
|
307
309
|
as a seq filter on outbound events, and the SDK dedups by
|
|
308
310
|
``event_id``, so repeat observers don't see duplicates.
|
|
311
|
+
|
|
312
|
+
``auth_filters`` is what the route's ``check_thread_stream_auth``
|
|
313
|
+
returned, passed on so the stream doesn't run the read handler again.
|
|
309
314
|
"""
|
|
310
315
|
if self._thread_source_task is not None:
|
|
311
316
|
return
|
|
312
317
|
self._thread_source_task = asyncio.create_task(
|
|
313
|
-
self._consume_thread_stream(last_event_id="-")
|
|
318
|
+
self._consume_thread_stream(last_event_id="-", auth_filters=auth_filters)
|
|
314
319
|
)
|
|
315
320
|
|
|
316
|
-
async def _consume_thread_stream(
|
|
321
|
+
async def _consume_thread_stream(
|
|
322
|
+
self, *, last_event_id: str | None, auth_filters: Any
|
|
323
|
+
) -> None:
|
|
317
324
|
try:
|
|
318
325
|
async for (
|
|
319
326
|
event,
|
|
@@ -324,6 +331,7 @@ class ThreadRunManager:
|
|
|
324
331
|
self._thread_id,
|
|
325
332
|
stream_modes=["run_modes", "lifecycle"],
|
|
326
333
|
last_event_id=last_event_id,
|
|
334
|
+
auth_filters=auth_filters,
|
|
327
335
|
):
|
|
328
336
|
if not run_id or run_id == "*":
|
|
329
337
|
continue
|
|
@@ -781,40 +789,15 @@ class ThreadRunManager:
|
|
|
781
789
|
if not capabilities.ok:
|
|
782
790
|
raise ProtocolV2UnsupportedError(capabilities)
|
|
783
791
|
|
|
792
|
+
# Drop server-owned configurable and metadata keys before the run
|
|
793
|
+
# config is built, as the REST request parser does for its bodies.
|
|
794
|
+
sanitize_reserved_keys(params)
|
|
784
795
|
config = params.get("config") if _is_record(params.get("config")) else {}
|
|
785
796
|
|
|
786
|
-
# ``input.respond`` checked its interrupts already and always resumes
|
|
787
|
-
# the lookups below can wait seconds on a busy thread.
|
|
788
|
-
force_resume = params.get("force_resume")
|
|
789
|
-
current_run = None if force_resume else await self._fetch_current_run()
|
|
790
|
-
|
|
791
|
-
current_status = None
|
|
792
|
-
if current_run is not None:
|
|
793
|
-
current_status = (
|
|
794
|
-
current_run["status"]
|
|
795
|
-
if _is_record(current_run)
|
|
796
|
-
else getattr(current_run, "status", None)
|
|
797
|
-
)
|
|
798
|
-
|
|
799
|
-
has_interrupts = False
|
|
800
|
-
if params.get("input") is not None and not force_resume:
|
|
801
|
-
has_interrupts = await self._has_pending_interrupts()
|
|
802
|
-
if has_interrupts and not _keyed_by_interrupt_id(params["input"]):
|
|
803
|
-
# One answer can't say which of several pending interrupts it is
|
|
804
|
-
# for: langgraph fails the run, or picks one when they share a
|
|
805
|
-
# subgraph task. Only a paused thread's row is current, so a run in
|
|
806
|
-
# flight keeps resuming as before.
|
|
807
|
-
paused_on = await self._collect_interrupt_ids_unless_busy()
|
|
808
|
-
if paused_on is not None and len(paused_on) > 1:
|
|
809
|
-
raise _AmbiguousResumeError(
|
|
810
|
-
f"run.start input can't say which of the {len(paused_on)} "
|
|
811
|
-
"pending interrupts it answers; answer them with input.respond."
|
|
812
|
-
)
|
|
813
|
-
|
|
797
|
+
# ``input.respond`` checked its interrupts already and always resumes.
|
|
814
798
|
is_resume = params.get("input") is not None and (
|
|
815
|
-
force_resume
|
|
816
|
-
or (
|
|
817
|
-
or has_interrupts
|
|
799
|
+
bool(params.get("force_resume"))
|
|
800
|
+
or await self._run_start_resumes(params["input"])
|
|
818
801
|
)
|
|
819
802
|
|
|
820
803
|
run_config: dict[str, Any] = {
|
|
@@ -903,6 +886,16 @@ class ThreadRunManager:
|
|
|
903
886
|
except Exception:
|
|
904
887
|
raise
|
|
905
888
|
|
|
889
|
+
# ``input.respond`` clears the interrupts it answered itself.
|
|
890
|
+
if is_resume and not params.get("force_resume") and self._session is not None:
|
|
891
|
+
# Until the stream rebinds to the resumed run, input sent next must
|
|
892
|
+
# not answer these interrupts a second time.
|
|
893
|
+
answered = params["input"]
|
|
894
|
+
if _keyed_by_interrupt_id(answered):
|
|
895
|
+
for interrupt_id in answered:
|
|
896
|
+
self._session.clear_pending_interrupt(interrupt_id)
|
|
897
|
+
else:
|
|
898
|
+
self._session._pending_interrupts.clear()
|
|
906
899
|
return run
|
|
907
900
|
|
|
908
901
|
async def _ensure_run_session(
|
|
@@ -959,6 +952,7 @@ class ThreadRunManager:
|
|
|
959
952
|
get_thread_state=self._make_get_thread_state(thread_id),
|
|
960
953
|
source=None,
|
|
961
954
|
send=self._make_send(),
|
|
955
|
+
max_buffer_size=None if self._replay_buffer else 0,
|
|
962
956
|
)
|
|
963
957
|
|
|
964
958
|
self._session = session
|
|
@@ -1086,19 +1080,6 @@ class ThreadRunManager:
|
|
|
1086
1080
|
# Run lookup
|
|
1087
1081
|
# ------------------------------------------------------------------
|
|
1088
1082
|
|
|
1089
|
-
async def _fetch_current_run(self) -> dict[str, Any] | None:
|
|
1090
|
-
"""Fetch the currently-bound run from storage.
|
|
1091
|
-
|
|
1092
|
-
``Runs.get`` uses keyword-only ``thread_id`` / ``ctx`` args so we
|
|
1093
|
-
cannot call it positionally. We also rely on ``ApiRoute``'s
|
|
1094
|
-
``with_user`` to populate the auth context var instead of
|
|
1095
|
-
passing ``ctx=self._auth`` (which is a starlette
|
|
1096
|
-
``AuthCredentials``, not a ``BaseAuthContext``).
|
|
1097
|
-
"""
|
|
1098
|
-
if self._current_run_id is None:
|
|
1099
|
-
return None
|
|
1100
|
-
return await self._fetch_run_by_id(self._current_run_id)
|
|
1101
|
-
|
|
1102
1083
|
async def _fetch_run_by_id(self, run_id: str) -> dict[str, Any] | None:
|
|
1103
1084
|
"""Fetch a run by id without searching for the latest run."""
|
|
1104
1085
|
from langgraph_runtime.database import connect # noqa: PLC0415
|
|
@@ -1127,41 +1108,6 @@ class ThreadRunManager:
|
|
|
1127
1108
|
# Interrupt detection
|
|
1128
1109
|
# ------------------------------------------------------------------
|
|
1129
1110
|
|
|
1130
|
-
async def _lookup_interrupt_in_thread_state(
|
|
1131
|
-
self, interrupt_id: str
|
|
1132
|
-
) -> list[str] | None:
|
|
1133
|
-
"""Existence check for ``interrupt_id`` against persisted thread state.
|
|
1134
|
-
|
|
1135
|
-
Fallback for ``input.respond`` on stateless HTTP transports, where the
|
|
1136
|
-
fresh session hasn't observed ``input.requested`` yet. Backed by the
|
|
1137
|
-
durable thread-row map (see :meth:`_collect_persisted_interrupt_ids`).
|
|
1138
|
-
|
|
1139
|
-
Returns ``[]`` if the interrupt exists, else ``None``. The ``[]`` is a
|
|
1140
|
-
sentinel, not a namespace — the map carries no namespace, so callers
|
|
1141
|
-
must treat it as "found, namespace unknown" and trust the client claim.
|
|
1142
|
-
"""
|
|
1143
|
-
found = await self._collect_persisted_interrupt_ids()
|
|
1144
|
-
if found is None:
|
|
1145
|
-
return None
|
|
1146
|
-
return [] if interrupt_id in found else None
|
|
1147
|
-
|
|
1148
|
-
async def _collect_persisted_interrupt_ids(self) -> set[str] | None:
|
|
1149
|
-
"""Collect interrupt ids from the durable thread-row ``interrupts`` map.
|
|
1150
|
-
|
|
1151
|
-
``Threads.set_status`` writes ``{task_id: [{id, value}, ...]}`` onto the
|
|
1152
|
-
thread row atomically with the status whenever a run pauses. Reading it
|
|
1153
|
-
is a plain row fetch (same ``connect()`` + ``Threads.get`` path as
|
|
1154
|
-
:meth:`_get_thread_assistant_id`) — unlike ``Threads.State.get`` it does
|
|
1155
|
-
not rebuild the graph, so it can't spuriously raise and reject a
|
|
1156
|
-
legitimate resume, and it survives reconnects/redeploys.
|
|
1157
|
-
|
|
1158
|
-
Returns the set of interrupt ids (empty when none are pending), or
|
|
1159
|
-
``None`` if the fetch failed — callers treat ``None`` as "unavailable"
|
|
1160
|
-
and fall open rather than fabricating ``no_such_interrupt``.
|
|
1161
|
-
"""
|
|
1162
|
-
thread = await self._fetch_thread_row()
|
|
1163
|
-
return None if thread is None else self._interrupt_ids(thread)
|
|
1164
|
-
|
|
1165
1111
|
async def _fetch_thread_row(self) -> dict[str, Any] | None:
|
|
1166
1112
|
"""Return the durable thread row, or ``None`` if the fetch failed."""
|
|
1167
1113
|
from langgraph_runtime.database import connect # noqa: PLC0415
|
|
@@ -1182,6 +1128,11 @@ class ThreadRunManager:
|
|
|
1182
1128
|
|
|
1183
1129
|
@staticmethod
|
|
1184
1130
|
def _interrupt_ids(thread: dict[str, Any]) -> set[str]:
|
|
1131
|
+
"""Ids in the row's ``interrupts`` map, written with the status when a run ends.
|
|
1132
|
+
|
|
1133
|
+
Read instead of ``Threads.State.get``, which rebuilds the graph and can
|
|
1134
|
+
raise, turning a valid answer into ``no_such_interrupt``.
|
|
1135
|
+
"""
|
|
1185
1136
|
interrupts_map = thread.get("interrupts")
|
|
1186
1137
|
if not _is_record(interrupts_map):
|
|
1187
1138
|
return set()
|
|
@@ -1197,40 +1148,6 @@ class ThreadRunManager:
|
|
|
1197
1148
|
found.add(entry_id)
|
|
1198
1149
|
return found
|
|
1199
1150
|
|
|
1200
|
-
async def _fetch_thread_status(self) -> str | None:
|
|
1201
|
-
"""Return durable thread status, or ``None`` if unreadable."""
|
|
1202
|
-
from langgraph_runtime.database import connect # noqa: PLC0415
|
|
1203
|
-
|
|
1204
|
-
try:
|
|
1205
|
-
async with connect() as conn:
|
|
1206
|
-
result = await self._threads.get(conn, self._thread_id)
|
|
1207
|
-
thread = await anext(result) if hasattr(result, "__anext__") else result
|
|
1208
|
-
except Exception:
|
|
1209
|
-
return None
|
|
1210
|
-
status = thread.get("status") if _is_record(thread) else None
|
|
1211
|
-
return status if isinstance(status, str) else None
|
|
1212
|
-
|
|
1213
|
-
async def _collect_settled_interrupt_ids(self) -> set[str] | None:
|
|
1214
|
-
"""Persisted interrupt ids, polling while the thread is still ``busy``."""
|
|
1215
|
-
ids = await self._collect_persisted_interrupt_ids()
|
|
1216
|
-
if ids is None:
|
|
1217
|
-
return None
|
|
1218
|
-
if ids:
|
|
1219
|
-
return ids
|
|
1220
|
-
deadline = time.monotonic() + _INTERRUPT_SETTLE_TIMEOUT_SECONDS
|
|
1221
|
-
while True:
|
|
1222
|
-
status = await self._fetch_thread_status()
|
|
1223
|
-
if status != _IN_FLIGHT_THREAD_STATUS:
|
|
1224
|
-
return await self._collect_persisted_interrupt_ids()
|
|
1225
|
-
if time.monotonic() >= deadline:
|
|
1226
|
-
return set()
|
|
1227
|
-
await asyncio.sleep(_INTERRUPT_SETTLE_POLL_INTERVAL_SECONDS)
|
|
1228
|
-
ids = await self._collect_persisted_interrupt_ids()
|
|
1229
|
-
if ids is None:
|
|
1230
|
-
return None
|
|
1231
|
-
if ids:
|
|
1232
|
-
return ids
|
|
1233
|
-
|
|
1234
1151
|
async def _collect_interrupt_ids_unless_busy(self) -> set[str] | None:
|
|
1235
1152
|
"""Persisted interrupt ids, or ``None`` while a run is in flight.
|
|
1236
1153
|
|
|
@@ -1247,16 +1164,45 @@ class ThreadRunManager:
|
|
|
1247
1164
|
return None
|
|
1248
1165
|
return self._interrupt_ids(thread)
|
|
1249
1166
|
|
|
1250
|
-
async def
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1167
|
+
async def _run_start_resumes(self, value: Any) -> bool:
|
|
1168
|
+
"""Whether ``run.start`` input answers the interrupts the thread is paused on.
|
|
1169
|
+
|
|
1170
|
+
Decided without waiting, from one read of the thread row. Interrupts
|
|
1171
|
+
reach the row only when their run ends, so while a run is in flight the
|
|
1172
|
+
input is a new run for the multitask strategy (``enqueue``, the default,
|
|
1173
|
+
queues it behind the running one), never an answer for whichever
|
|
1174
|
+
interrupt is open when that run pauses. The exception is the run this
|
|
1175
|
+
connection streams: the session reports it ``interrupted`` as soon as it
|
|
1176
|
+
raises an interrupt, so input sent after that answers the streamed
|
|
1177
|
+
interrupts, until this connection resumes them or that run ends. When
|
|
1178
|
+
the row can't be read, the streamed interrupts decide. The last run's
|
|
1179
|
+
status isn't consulted: a run that pauses on an interrupt ends
|
|
1180
|
+
``success``, and ``interrupted`` means it was cancelled; any interrupt it
|
|
1181
|
+
left open is in the row.
|
|
1182
|
+
|
|
1183
|
+
Raises ``_AmbiguousResumeError`` when the input would answer several
|
|
1184
|
+
interrupts and isn't keyed by interrupt id.
|
|
1185
|
+
"""
|
|
1186
|
+
if _keyed_by_interrupt_id(value):
|
|
1257
1187
|
return True
|
|
1258
|
-
|
|
1259
|
-
|
|
1188
|
+
session = self._session
|
|
1189
|
+
streamed = set(session._pending_interrupts) if session is not None else set()
|
|
1190
|
+
thread = await self._fetch_thread_row()
|
|
1191
|
+
if thread is None:
|
|
1192
|
+
paused_on = streamed
|
|
1193
|
+
elif thread.get("status") == _IN_FLIGHT_THREAD_STATUS:
|
|
1194
|
+
paused_on = set() if session is None or session.run_done else streamed
|
|
1195
|
+
else:
|
|
1196
|
+
paused_on = self._interrupt_ids(thread)
|
|
1197
|
+
if len(paused_on) > 1:
|
|
1198
|
+
# One answer can't say which of several pending interrupts it is
|
|
1199
|
+
# for: langgraph fails the run, or picks one when they share a
|
|
1200
|
+
# subgraph task.
|
|
1201
|
+
raise _AmbiguousResumeError(
|
|
1202
|
+
f"run.start input can't say which of the {len(paused_on)} "
|
|
1203
|
+
"pending interrupts it answers; answer them with input.respond."
|
|
1204
|
+
)
|
|
1205
|
+
return bool(paused_on)
|
|
1260
1206
|
|
|
1261
1207
|
# ------------------------------------------------------------------
|
|
1262
1208
|
# Command forwarding
|
|
@@ -228,8 +228,8 @@ class EventStreamingSession:
|
|
|
228
228
|
) -> None:
|
|
229
229
|
if max_buffer_size is None:
|
|
230
230
|
max_buffer_size = LSD_PROTOCOL_V2_BUFFER_SIZE
|
|
231
|
-
if max_buffer_size <
|
|
232
|
-
raise ValueError("max_buffer_size must be a positive integer")
|
|
231
|
+
if max_buffer_size < 0:
|
|
232
|
+
raise ValueError("max_buffer_size must be zero or a positive integer")
|
|
233
233
|
self._run_id = run_id
|
|
234
234
|
self._thread_id = thread_id
|
|
235
235
|
self._initial_run = initial_run
|
|
@@ -251,12 +251,14 @@ class EventStreamingSession:
|
|
|
251
251
|
self._buffer: list[dict[str, Any]] = []
|
|
252
252
|
self._source_task: asyncio.Task[None] | None = None
|
|
253
253
|
self._next_seq = 0
|
|
254
|
+
# 0 keeps no replay buffer, for transports that never replay from it.
|
|
254
255
|
self._max_buffer_size = max_buffer_size
|
|
255
256
|
self._root_graph_name = "root"
|
|
256
257
|
self._terminal_lifecycle_emitted = False
|
|
257
258
|
# Maps interrupt_id -> namespace that emitted it, so ``input.respond``
|
|
258
259
|
# from the client can be verified against the originating subgraph.
|
|
259
260
|
self._pending_interrupts: dict[str, Namespace] = {}
|
|
261
|
+
self.run_done = False
|
|
260
262
|
# Latches once the underlying transport starts rejecting sends so
|
|
261
263
|
# we don't spam the logs for every subsequent event on a dead
|
|
262
264
|
# WebSocket / SSE stream. The session is torn down by ``close()``
|
|
@@ -374,6 +376,7 @@ class EventStreamingSession:
|
|
|
374
376
|
|
|
375
377
|
async def complete(self) -> None:
|
|
376
378
|
"""Emit terminal lifecycle when an external source reports run completion."""
|
|
379
|
+
self.run_done = True
|
|
377
380
|
await self._emit_terminal_lifecycle()
|
|
378
381
|
|
|
379
382
|
async def handle_event_streaming_command(
|
|
@@ -971,7 +974,8 @@ class EventStreamingSession:
|
|
|
971
974
|
return _synth_event_id(self._run_id, str(self._next_seq))
|
|
972
975
|
|
|
973
976
|
async def _push_event(self, event: dict[str, Any]) -> None:
|
|
974
|
-
self.
|
|
977
|
+
if self._max_buffer_size:
|
|
978
|
+
self._buffer.append(event)
|
|
975
979
|
reporter = get_otlp_metrics_reporter()
|
|
976
980
|
if len(self._buffer) > self._max_buffer_size:
|
|
977
981
|
evicted = len(self._buffer) - self._max_buffer_size
|
|
@@ -1036,9 +1040,10 @@ class EventStreamingSession:
|
|
|
1036
1040
|
callback for events with ``seq > since`` that match the filter,
|
|
1037
1041
|
then the subscription is activated so live events flow.
|
|
1038
1042
|
|
|
1039
|
-
Returns the number of events replayed. Used by
|
|
1040
|
-
``subscription.subscribe``
|
|
1041
|
-
|
|
1043
|
+
Returns the number of events replayed. Used by the WebSocket
|
|
1044
|
+
``subscription.subscribe`` and ``subscription.reconnect`` commands;
|
|
1045
|
+
``since`` resumes after a reconnect. SSE streams replay from the
|
|
1046
|
+
thread's event stream instead.
|
|
1042
1047
|
|
|
1043
1048
|
Raises :class:`ResumeGap` when *since* is older than the
|
|
1044
1049
|
session's retained buffer head. This lets callers surface a
|
|
@@ -68,6 +68,10 @@ logger = structlog.stdlib.get_logger(__name__)
|
|
|
68
68
|
# only need graph_id (from metadata) and the thread config.
|
|
69
69
|
THREAD_METADATA_READ_MASK_PATHS: list[str] = ["metadata", "config"]
|
|
70
70
|
|
|
71
|
+
# Read-mask preset for an auth/existence check that discards the thread body;
|
|
72
|
+
# Go's ``repos.ThreadExistenceCheckMask``.
|
|
73
|
+
THREAD_EXISTENCE_CHECK_READ_MASK_PATHS: list[str] = []
|
|
74
|
+
|
|
71
75
|
|
|
72
76
|
THREAD_STATUS_TO_PB = {
|
|
73
77
|
"idle": enum_thread_status.idle,
|
|
@@ -971,13 +975,20 @@ class Threads(Authenticated):
|
|
|
971
975
|
stream_modes: list[str] | None = None,
|
|
972
976
|
last_event_id: str | None = None,
|
|
973
977
|
ctx: Any = None,
|
|
978
|
+
auth_filters: list[pb.AuthFilter] | None = None,
|
|
974
979
|
):
|
|
975
|
-
"""Stream thread events with their run id for protocol demux.
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
980
|
+
"""Stream thread events with their run id for protocol demux.
|
|
981
|
+
|
|
982
|
+
``auth_filters`` comes from a ``check_thread_stream_auth`` the
|
|
983
|
+
caller already ran, so the read handler doesn't run twice. With
|
|
984
|
+
``None``, the handler runs here.
|
|
985
|
+
"""
|
|
986
|
+
if auth_filters is None:
|
|
987
|
+
auth_filters = await Threads.Stream.handle_event(
|
|
988
|
+
ctx,
|
|
989
|
+
"read",
|
|
990
|
+
Auth.types.ThreadsRead(thread_id=UUID(str(thread_id))),
|
|
991
|
+
)
|
|
981
992
|
|
|
982
993
|
stream_modes_pb = _map_thread_stream_modes(stream_modes)
|
|
983
994
|
|
|
@@ -1013,6 +1024,43 @@ class Threads(Authenticated):
|
|
|
1013
1024
|
_handle_grpc_error(e)
|
|
1014
1025
|
raise
|
|
1015
1026
|
|
|
1027
|
+
@staticmethod
|
|
1028
|
+
async def check_thread_stream_auth(
|
|
1029
|
+
thread_id: UUID,
|
|
1030
|
+
ctx: Any = None,
|
|
1031
|
+
) -> list[pb.AuthFilter]:
|
|
1032
|
+
"""Raise 403/404 before a thread stream's response starts.
|
|
1033
|
+
|
|
1034
|
+
Same check as the Go ``Stream`` RPC: the thread is looked up only
|
|
1035
|
+
when custom auth returns filters. Without filters, a thread that
|
|
1036
|
+
doesn't exist yet must stay streamable, because the Python SDK
|
|
1037
|
+
opens its lifecycle stream on ``async with client.threads.stream()``,
|
|
1038
|
+
before the ``run.start`` that creates the thread.
|
|
1039
|
+
|
|
1040
|
+
Returns the auth filters for ``join_event_streaming``.
|
|
1041
|
+
"""
|
|
1042
|
+
auth_filters = await Threads.Stream.handle_event(
|
|
1043
|
+
ctx,
|
|
1044
|
+
"read",
|
|
1045
|
+
Auth.types.ThreadsRead(thread_id=thread_id),
|
|
1046
|
+
)
|
|
1047
|
+
if not auth_filters:
|
|
1048
|
+
return auth_filters
|
|
1049
|
+
client = await get_shared_client()
|
|
1050
|
+
try:
|
|
1051
|
+
await client.threads.Get(
|
|
1052
|
+
pb.GetThreadRequest(
|
|
1053
|
+
thread_id=pb.UUID(value=_normalize_uuid(thread_id)),
|
|
1054
|
+
filters=auth_filters,
|
|
1055
|
+
read_mask=field_mask_pb2.FieldMask(
|
|
1056
|
+
paths=THREAD_EXISTENCE_CHECK_READ_MASK_PATHS
|
|
1057
|
+
),
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
except AioRpcError as e:
|
|
1061
|
+
_handle_grpc_error(e)
|
|
1062
|
+
return auth_filters
|
|
1063
|
+
|
|
1016
1064
|
class State(Authenticated):
|
|
1017
1065
|
# treat this like threads resource
|
|
1018
1066
|
resource = "threads"
|
langgraph_api/models/run.py
CHANGED
|
@@ -36,6 +36,7 @@ from langgraph_api.tracing_session import resolve_tracing_session_name
|
|
|
36
36
|
from langgraph_api.utils import AsyncConnectionProto, get_auth_ctx, get_user_id
|
|
37
37
|
from langgraph_api.utils.headers import get_configurable_headers
|
|
38
38
|
from langgraph_api.utils.uuids import uuid7
|
|
39
|
+
from langgraph_api.validation import strip_reserved
|
|
39
40
|
from langgraph_api.webhook import validate_webhook_url_or_raise
|
|
40
41
|
|
|
41
42
|
if IS_POSTGRES_OR_GRPC_BACKEND:
|
|
@@ -261,6 +262,12 @@ async def create_valid_run(
|
|
|
261
262
|
if checkpoint_id:
|
|
262
263
|
configurable["checkpoint_id"] = str(checkpoint_id)
|
|
263
264
|
if checkpoint := payload.get("checkpoint"):
|
|
265
|
+
# Cron payloads stored before requests had checkpoint stripped can still carry server-owned keys.
|
|
266
|
+
strip_reserved(
|
|
267
|
+
checkpoint,
|
|
268
|
+
"checkpoint",
|
|
269
|
+
message="Stripped reserved keys from stored checkpoint",
|
|
270
|
+
)
|
|
264
271
|
configurable.update(checkpoint)
|
|
265
272
|
configurable.update(get_configurable_headers(headers))
|
|
266
273
|
# cron_id is server-owned: drop any value merged from the request, then stamp the scheduler's.
|
langgraph_api/utils/headers.py
CHANGED
|
@@ -8,6 +8,8 @@ from collections.abc import Mapping
|
|
|
8
8
|
import orjson
|
|
9
9
|
import structlog
|
|
10
10
|
|
|
11
|
+
from langgraph_api.validation import RESERVED_CONFIGURABLE_KEYS
|
|
12
|
+
|
|
11
13
|
logger = structlog.stdlib.get_logger(__name__)
|
|
12
14
|
|
|
13
15
|
LANGSMITH_METADATA = "langsmith-metadata"
|
|
@@ -146,6 +148,9 @@ def should_include_header(key: str) -> bool:
|
|
|
146
148
|
or key == "authorization"
|
|
147
149
|
):
|
|
148
150
|
return False
|
|
151
|
+
# Server-owned configurable keys are never taken from a request header.
|
|
152
|
+
if key in RESERVED_CONFIGURABLE_KEYS:
|
|
153
|
+
return False
|
|
149
154
|
|
|
150
155
|
include_patterns, exclude_patterns = get_header_patterns("configurable_headers")
|
|
151
156
|
|
langgraph_api/validation.py
CHANGED
|
@@ -112,7 +112,7 @@ _RESERVED_KEYS_SET = frozenset(RESERVED_CONFIGURABLE_KEYS)
|
|
|
112
112
|
_RESERVED_METADATA_KEYS_SET = frozenset(RESERVED_METADATA_KEYS)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
def
|
|
115
|
+
def strip_reserved(
|
|
116
116
|
d: dict,
|
|
117
117
|
location: str,
|
|
118
118
|
reserved_keys: frozenset[str] = _RESERVED_KEYS_SET,
|
|
@@ -134,7 +134,7 @@ def _strip_metadata(d: dict, location: str) -> None:
|
|
|
134
134
|
"""Strip reserved keys from a ``metadata`` sub-dict if present."""
|
|
135
135
|
metadata = d.get("metadata")
|
|
136
136
|
if isinstance(metadata, dict):
|
|
137
|
-
|
|
137
|
+
strip_reserved(
|
|
138
138
|
metadata,
|
|
139
139
|
location,
|
|
140
140
|
reserved_keys=_RESERVED_METADATA_KEYS_SET,
|
|
@@ -184,22 +184,22 @@ def sanitize_reserved_keys(data: typing.Any, *, strip_metadata: bool = True) ->
|
|
|
184
184
|
if isinstance(config, dict):
|
|
185
185
|
configurable = config.get("configurable")
|
|
186
186
|
if isinstance(configurable, dict):
|
|
187
|
-
|
|
187
|
+
strip_reserved(configurable, "config.configurable")
|
|
188
188
|
|
|
189
189
|
# Config schema: top-level configurable
|
|
190
190
|
configurable = data.get("configurable")
|
|
191
191
|
if isinstance(configurable, dict):
|
|
192
|
-
|
|
192
|
+
strip_reserved(configurable, "configurable")
|
|
193
193
|
|
|
194
194
|
# write schemas: context
|
|
195
195
|
context = data.get("context")
|
|
196
196
|
if isinstance(context, dict):
|
|
197
|
-
|
|
197
|
+
strip_reserved(context, "context")
|
|
198
198
|
|
|
199
199
|
# checkpoint: merged into configurable on run create and when a cron fires
|
|
200
200
|
checkpoint = data.get("checkpoint")
|
|
201
201
|
if isinstance(checkpoint, dict):
|
|
202
|
-
|
|
202
|
+
strip_reserved(checkpoint, "checkpoint")
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
def should_strip_reserved_metadata(schema: typing.Any) -> bool:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
langgraph_api/__init__.py,sha256=
|
|
1
|
+
langgraph_api/__init__.py,sha256=lKV0HWITd9TkyE-O95yarED1frbG86ds67s3ivE3cFk,28
|
|
2
2
|
langgraph_api/_factory_utils.py,sha256=_FqW0olMu2PGLsnRq0ylt3a4jDCe12YIFHExMXI6Nyo,5976
|
|
3
3
|
langgraph_api/asgi_transport.py,sha256=XApY3lIWBZTMbbsl8dDJzl0cLGirmAGE0SifqZUnXvs,11896
|
|
4
4
|
langgraph_api/asyncio.py,sha256=-p-Vx89TnErypdPcWb-2EdbsSvhdIdHfriCFG7UP7ls,12444
|
|
@@ -7,7 +7,7 @@ langgraph_api/cache.py,sha256=xtBTkEQOP0GJ0wb9EH_BdIvaDfRxPG5DPNnEh2Zmcmg,16198
|
|
|
7
7
|
langgraph_api/cli.py,sha256=ATtS9s9Cx7QNiGPJceKnMCko29A25ZA-xz39fdxmgfg,22389
|
|
8
8
|
langgraph_api/command.py,sha256=d-k8h6H4ix1n7fSZ-Zb01NbSkEyqrD6cMKfDFXEIYEw,821
|
|
9
9
|
langgraph_api/cron_scheduler.py,sha256=H1jPymC68A1MYyjfdApX4-hELiGBlJpGgSOsN8qhc-8,5595
|
|
10
|
-
langgraph_api/drain.py,sha256=
|
|
10
|
+
langgraph_api/drain.py,sha256=MLUm3fDq3R8yxrZ-1W3TeCfhNzgNSzUA7TvB-D6K2_s,6108
|
|
11
11
|
langgraph_api/errors.py,sha256=QJSriNZn3VMnMzcxbTNmUgT8u1a0GddpMXamJ4A_a6o,2583
|
|
12
12
|
langgraph_api/feature_flags.py,sha256=AWkWe--MlzoF7O0tazXNxFJYa9P9CZSxPSpt7hL9p6o,3938
|
|
13
13
|
langgraph_api/graph.py,sha256=uQaC_yOWRFZIZwQt5WGdjnVW5iB0WaaBniUcUV82Y5Q,40594
|
|
@@ -35,7 +35,7 @@ langgraph_api/stream.py,sha256=SMrditrbTJ7QtKW-ThpYAPiVsQD-ot_tQeGJIp9PKGI,36519
|
|
|
35
35
|
langgraph_api/stream_v2.py,sha256=cmbE566boEgKkjcqqf8OvDqjWh8VzvCCsTTlvUF5Drg,10762
|
|
36
36
|
langgraph_api/traceblock.py,sha256=GhgAtLhAQ8Z8LHtRQq3par-rfPpRBQp2Q7WBACJq1NI,677
|
|
37
37
|
langgraph_api/tracing_session.py,sha256=gmfv-BW1XRar6suVur1P4rTVKWkjElDEb2hhkBduWOs,728
|
|
38
|
-
langgraph_api/validation.py,sha256=
|
|
38
|
+
langgraph_api/validation.py,sha256=_fmqjbETfsrc4KKJLtfhVjELD-1WzEVrK6G1pfDrmtk,14930
|
|
39
39
|
langgraph_api/webhook.py,sha256=qXEtkE6orek2POeOQmPRsEarJgXIYp-LBrZB-OwITxc,9572
|
|
40
40
|
langgraph_api/worker.py,sha256=Y6bsWW5shcfqYw-vwRV3H9D8XLXltPSJMOxBMOJyZzw,22201
|
|
41
41
|
langgraph_api/worker_runtime.py,sha256=zzVW7A4vHyJE6DRxdjRZIa2U334Eh5oO157SyWmQ3G8,11861
|
|
@@ -46,7 +46,7 @@ langgraph_api/api/__init__.py,sha256=E95h8fdtBERIRsEEoRu_n3INZc1vb5LK5eHvR9ss-V8
|
|
|
46
46
|
langgraph_api/api/a2a.py,sha256=dYuxX4HhU0RlsOWKraA1gYD58FyyVXeR0QihLn4xNfg,148659
|
|
47
47
|
langgraph_api/api/a2ui.py,sha256=Dx9qwwIOmQ0MfgOwL-UZabBI0F7DEvz3qG1cFkjvnjE,3108
|
|
48
48
|
langgraph_api/api/assistants.py,sha256=iWrmWsAXpO9kgAb2ihKuJPCABYYf_7I9BPEITwwBsrA,19822
|
|
49
|
-
langgraph_api/api/event_streaming.py,sha256=
|
|
49
|
+
langgraph_api/api/event_streaming.py,sha256=0Nu2WQdTz1f-uxmZ01mz2cr_Bx2IqZnvOiqADN0wH1o,22063
|
|
50
50
|
langgraph_api/api/meta.py,sha256=5s017GfMs-Ghq6Z5K6yW4UjbVFzCsmeXKn4UEJmfs98,4757
|
|
51
51
|
langgraph_api/api/openapi.py,sha256=Zkdlb9mjrQyHro1TtrDIWVuaBDovxx-uGWJ1fZMOg54,12604
|
|
52
52
|
langgraph_api/api/profile.py,sha256=CA1ZkHALOuP8orYTICnEhcG_JnnA2wnyjbWyeb117jA,3455
|
|
@@ -77,7 +77,7 @@ langgraph_api/auth/studio_user.py,sha256=Lx3eMsRA8gxZ3--ApL09hDW84A6qIncaZoBZjSg
|
|
|
77
77
|
langgraph_api/auth/langsmith/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
78
78
|
langgraph_api/auth/langsmith/backend.py,sha256=gaSuZC8JO32XHe8IdqttNpBGO6PCVrtXaq1Negc7vX0,3857
|
|
79
79
|
langgraph_api/auth/langsmith/client.py,sha256=79kwCVeHU64nsHsxWipfZhf44lM6vfs2nlfTxlJF6LU,4142
|
|
80
|
-
langgraph_api/config/__init__.py,sha256
|
|
80
|
+
langgraph_api/config/__init__.py,sha256=-BOsd7Xqrtw89dPYnqM71i2GdGRzEPspOBJOCJ9HfWk,31699
|
|
81
81
|
langgraph_api/config/_parse.py,sha256=RpfZgFAPJlRMv13AzGr3kAYbIrqHcgjzO8IgeboTw4A,3922
|
|
82
82
|
langgraph_api/config/schemas.py,sha256=RaV-huiOALaJDsGi-wVp7Ghz8aE7UDXgL9YB2TYvQKA,21439
|
|
83
83
|
langgraph_api/encryption/__init__.py,sha256=gaCZ00CocSbqSqrDn6XJHaSp2CZCnC8qnrD9G4fbzyI,363
|
|
@@ -91,8 +91,8 @@ langgraph_api/event_streaming/capabilities.py,sha256=qjVbhCjl1VEQPGeiDxeJAhYGI_7
|
|
|
91
91
|
langgraph_api/event_streaming/constants.py,sha256=eGsm-NvOlqV3gNxDO5vlr00FdngmgEQf59zuHSi_74E,1378
|
|
92
92
|
langgraph_api/event_streaming/event_normalizers.py,sha256=5bVSqGPW-Uh7WX91qgTfwpK433pCSv_wchpbzW8TLi4,2794
|
|
93
93
|
langgraph_api/event_streaming/namespace.py,sha256=aJDFt45Or2_bQdRpKJgdFhBgTDj6PYl7Coz5GzfbM84,1509
|
|
94
|
-
langgraph_api/event_streaming/service.py,sha256=
|
|
95
|
-
langgraph_api/event_streaming/session.py,sha256=
|
|
94
|
+
langgraph_api/event_streaming/service.py,sha256=t739MpvrkBrrRjdMw1ln3w-Lz1F52HcKiuinoNSELLU,52216
|
|
95
|
+
langgraph_api/event_streaming/session.py,sha256=sw5orsEKDTADaxCBURd9TNRAyhqqcWrp0yDkWuBrsY0,76170
|
|
96
96
|
langgraph_api/event_streaming/state_normalizers.py,sha256=vgT4O4tJPr9VDBMn1EP994ieDGDYP43sROnOyLjkEAE,13659
|
|
97
97
|
langgraph_api/event_streaming/types.py,sha256=RyZqfqgH-jmmmmAFQj5f6nH9M1rGK93zVG7nlmvqZgc,3647
|
|
98
98
|
langgraph_api/executor/__init__.py,sha256=mZBT8HSqe9pMlBpE7sw6DMI4ULB4xwNPWGkN9zp1B7k,302
|
|
@@ -106,7 +106,7 @@ langgraph_api/grpc/ops/assistants.py,sha256=hLDZrAahpGSjzWNzr8nQidTW_4MoCR6sZy5V
|
|
|
106
106
|
langgraph_api/grpc/ops/cache.py,sha256=MoPAmSuOwbt2Cud1YZJm_YJeaDHPFy1IiV4m0PDImgg,1399
|
|
107
107
|
langgraph_api/grpc/ops/crons.py,sha256=aDlvNVYIfSP8gtr5NptLiAj-bfMtJOuMWYIA2NifWm0,19713
|
|
108
108
|
langgraph_api/grpc/ops/runs.py,sha256=ID0oOVvwZEZKdLSNfPUVcA8k3fNrTra62jy0JElMjss,43832
|
|
109
|
-
langgraph_api/grpc/ops/threads.py,sha256=
|
|
109
|
+
langgraph_api/grpc/ops/threads.py,sha256=nWp2qrt4FxxM_SEW5GBEeiOiKk6qdTPmY2hPTcYgsjY,50912
|
|
110
110
|
langgraph_api/grpc/servicers/__init__.py,sha256=l8yqTA_gMbIj0xHw5-RZYo0JbjL-EpFD8RxPMgW_-bA,422
|
|
111
111
|
langgraph_api/grpc/servicers/checkpointer.py,sha256=kn9J3HQEmqggtOGiDxpEAeBVkVi4qDCJikj2bXo5k4g,10543
|
|
112
112
|
langgraph_api/grpc/servicers/encryption.py,sha256=ddAjRZ9btWBf4MPa7yd793japdCEi-DfEPi1Ynt8gzk,10563
|
|
@@ -147,7 +147,7 @@ langgraph_api/middleware/http_logger.py,sha256=79iMlqKdSP5sS6G-iuEAHl2h0Z2ygYDxs
|
|
|
147
147
|
langgraph_api/middleware/private_network.py,sha256=eQEzWI8epBNUCiNsMu9O27ofHBQ45M0p2OZy5YdUYos,2097
|
|
148
148
|
langgraph_api/middleware/request_id.py,sha256=-p230Q5jDJAJLmSZRqQvB4dFFkJS9B4Vwg6pUgQtI24,1259
|
|
149
149
|
langgraph_api/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
150
|
-
langgraph_api/models/run.py,sha256=
|
|
150
|
+
langgraph_api/models/run.py,sha256=MW7Q17G3veh3WCPwwyDVA9qkk-ih8MPo3QKqY-_Wyho,15795
|
|
151
151
|
langgraph_api/timing/__init__.py,sha256=UDMuNBmc5NqX0m5T-9PUFioCa1lNvd4Wv4juPIBRb-w,877
|
|
152
152
|
langgraph_api/timing/profiler.py,sha256=_n0qJYJMjCGo95RDuH5z8FIzePGNSNbnm3cvGmcgn6A,6186
|
|
153
153
|
langgraph_api/timing/startup.py,sha256=1Jp4-tquBhiE5glWQhXGX5XS2_006myJXHHZMOi15ng,6161
|
|
@@ -159,7 +159,7 @@ langgraph_api/utils/config.py,sha256=tyxWNnJhw_PbQ-18ekSFZxsN23d2COf6SmpNsU9X3m8
|
|
|
159
159
|
langgraph_api/utils/errors.py,sha256=51WNBkbk1JHlTvA-J2lgkA3PDj3BEGcnhIECwAihWgU,2728
|
|
160
160
|
langgraph_api/utils/extract.py,sha256=KaBzFc_WosmzRTtN9L5z4UAzJzGqCmcV_eGk9ATUg-o,3873
|
|
161
161
|
langgraph_api/utils/future.py,sha256=coCKJiRZCgoF_pEijVLR4v6mGGOjx_057vEkNwAC5gU,7414
|
|
162
|
-
langgraph_api/utils/headers.py,sha256=
|
|
162
|
+
langgraph_api/utils/headers.py,sha256=4012Nup4FRR8fxbDdqpX7eVJQtNUBqVwfAdUaNQKLnk,6563
|
|
163
163
|
langgraph_api/utils/network.py,sha256=CO0H95QO6R6xxBPQNU_TVaf4z3XbO7oX4qDdwbpmaZY,1190
|
|
164
164
|
langgraph_api/utils/retriable_client.py,sha256=WKdjquy6eIdeNuyO68AxVyskSgBAF_ViO-K-tmRCdDs,2551
|
|
165
165
|
langgraph_api/utils/stream_codec.py,sha256=rt7Vz2AMPetwBdson1Suof3IzM4m3UaPW7_o7TuCgeo,10243
|
|
@@ -180,7 +180,7 @@ langgraph_runtime/store.py,sha256=7mowndlsIroGHv3NpTSOZDJR0lCuaYMBoTnTrewjslw,11
|
|
|
180
180
|
LICENSE,sha256=SCVQGLQfwOllsRFa9-Z3m8IYu4pnR9Vh2oANUCJiKqI,3860
|
|
181
181
|
hatch_build.py,sha256=Nn9N-EFfVqmH5q3iCUrhqX6zc7qT1jLI3gWSBN2xgYQ,1738
|
|
182
182
|
logging.json,sha256=sBy8HDDPuucpLbLUD6e8t5fsiQjJoklZsbVHi2qQ7aQ,367
|
|
183
|
-
openapi.json,sha256=
|
|
183
|
+
openapi.json,sha256=yxvwPZ74Y-uXRR5di9YcP_3cVxE5RR_MOWba-50tCmk,226652
|
|
184
184
|
langgraph_grpc_common/__init__.py,sha256=sO7jFHekQxt6zZPhTPZAWLYLB_S8w7-VuJ7NyWFlFNo,97
|
|
185
185
|
langgraph_grpc_common/checkpointer.py,sha256=qNUrdS8veKuxy-S4-soCvsiB_P0SpoS5jdJ5q6JAzRg,15620
|
|
186
186
|
langgraph_grpc_common/serde.py,sha256=8q290nZ6TdxbEhCTzwEs44XGi2b-3pfrUeMYY5WGlTY,6179
|
|
@@ -258,8 +258,8 @@ langgraph_grpc_common/proto/store_pb2.py,sha256=VWJ7wtzL9pVmZZKbxMzOzCoiFn58bB2g
|
|
|
258
258
|
langgraph_grpc_common/proto/store_pb2.pyi,sha256=atXejijuYEb9tE0stGQdZcfA9ucMFj-ytTMwvkxwviM,29322
|
|
259
259
|
langgraph_grpc_common/proto/store_pb2_grpc.py,sha256=Kyt8eV2NXkjh3be_BYytGdD19kNf74961cruonMvCrw,3300
|
|
260
260
|
langgraph_grpc_common/proto/store_pb2_grpc.pyi,sha256=oS1h2cDoq2OjyM8xazcmc8LwRDC5oCAD0zto2QUmPQw,2027
|
|
261
|
-
langgraph_api-0.18.0.
|
|
262
|
-
langgraph_api-0.18.0.
|
|
263
|
-
langgraph_api-0.18.0.
|
|
264
|
-
langgraph_api-0.18.0.
|
|
265
|
-
langgraph_api-0.18.0.
|
|
261
|
+
langgraph_api-0.18.0.dev4.dist-info/METADATA,sha256=ivAuNjWbJAU_vF-F9u0At8L6u09I9C8Pz2NU8vXFeds,5879
|
|
262
|
+
langgraph_api-0.18.0.dev4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
263
|
+
langgraph_api-0.18.0.dev4.dist-info/entry_points.txt,sha256=hGedv8n7cgi41PypMfinwS_HfCwA7xJIfS0jAp8htV8,78
|
|
264
|
+
langgraph_api-0.18.0.dev4.dist-info/licenses/LICENSE,sha256=SCVQGLQfwOllsRFa9-Z3m8IYu4pnR9Vh2oANUCJiKqI,3860
|
|
265
|
+
langgraph_api-0.18.0.dev4.dist-info/RECORD,,
|
openapi.json
CHANGED
|
@@ -1722,6 +1722,26 @@
|
|
|
1722
1722
|
}
|
|
1723
1723
|
}
|
|
1724
1724
|
}
|
|
1725
|
+
},
|
|
1726
|
+
"404": {
|
|
1727
|
+
"description": "Not Found",
|
|
1728
|
+
"content": {
|
|
1729
|
+
"application/json": {
|
|
1730
|
+
"schema": {
|
|
1731
|
+
"$ref": "#/components/schemas/ErrorResponse"
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
"422": {
|
|
1737
|
+
"description": "Validation Error",
|
|
1738
|
+
"content": {
|
|
1739
|
+
"application/json": {
|
|
1740
|
+
"schema": {
|
|
1741
|
+
"$ref": "#/components/schemas/ErrorResponse"
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1725
1745
|
}
|
|
1726
1746
|
}
|
|
1727
1747
|
}
|
|
File without changes
|
{langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{langgraph_api-0.18.0.dev3.dist-info → langgraph_api-0.18.0.dev4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|