langgraph-api 0.17.0.dev3__py3-none-any.whl → 0.17.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.
- LICENSE +1 -1
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/threads.py +2 -7
- langgraph_api/cache.py +86 -26
- langgraph_api/config/__init__.py +45 -5
- langgraph_api/config/schemas.py +12 -12
- langgraph_api/cron_scheduler.py +16 -1
- langgraph_api/event_streaming/__init__.py +0 -1
- langgraph_api/feature_flags.py +35 -4
- langgraph_api/grpc/ops/crons.py +3 -2
- langgraph_api/grpc/ops/runs.py +3 -0
- langgraph_api/grpc/ops/threads.py +44 -0
- langgraph_api/js/package.json +1 -1
- langgraph_api/js/yarn.lock +7 -7
- langgraph_api/models/run.py +4 -0
- langgraph_api/schema.py +3 -0
- langgraph_api/store.py +7 -6
- langgraph_api/utils/cache.py +22 -10
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev4.dist-info}/METADATA +2 -2
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev4.dist-info}/RECORD +29 -29
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev4.dist-info}/licenses/LICENSE +1 -1
- langgraph_grpc_common/proto/checkpointer_pb2.pyi +1 -1
- langgraph_grpc_common/proto/core_api_pb2.py +135 -123
- langgraph_grpc_common/proto/core_api_pb2.pyi +163 -2
- langgraph_grpc_common/proto/core_api_pb2_grpc.py +47 -3
- langgraph_grpc_common/proto/core_api_pb2_grpc.pyi +15 -3
- openapi.json +8 -1
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev4.dist-info}/WHEEL +0 -0
- {langgraph_api-0.17.0.dev3.dist-info → langgraph_api-0.17.0.dev4.dist-info}/entry_points.txt +0 -0
LICENSE
CHANGED
langgraph_api/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.17.0.
|
|
1
|
+
__version__ = "0.17.0.dev4"
|
langgraph_api/api/threads.py
CHANGED
|
@@ -36,7 +36,6 @@ from langgraph_api.validation import (
|
|
|
36
36
|
ThreadStateSearch,
|
|
37
37
|
ThreadStateUpdate,
|
|
38
38
|
)
|
|
39
|
-
from langgraph_runtime import checkpoint as runtime_checkpoint
|
|
40
39
|
from langgraph_runtime.database import connect
|
|
41
40
|
from langgraph_runtime.retry import retry_db
|
|
42
41
|
|
|
@@ -349,17 +348,13 @@ async def get_thread_storage(request: ApiRequest):
|
|
|
349
348
|
"""Bytes a thread's checkpoints occupy, per table and per channel."""
|
|
350
349
|
thread_id = request.path_params["thread_id"]
|
|
351
350
|
validate_uuid(thread_id, "Invalid thread ID: must be a UUID")
|
|
352
|
-
|
|
353
|
-
if USE_CUSTOM_CHECKPOINTER or thread_storage is None:
|
|
351
|
+
if USE_CUSTOM_CHECKPOINTER or not hasattr(Threads, "storage"):
|
|
354
352
|
raise HTTPException(
|
|
355
353
|
status_code=501,
|
|
356
354
|
detail="Checkpoint storage is only reported for the built-in checkpointer.",
|
|
357
355
|
)
|
|
358
356
|
async with connect() as conn:
|
|
359
|
-
|
|
360
|
-
# The built-in tables are read directly; the core-api stand-in cannot run SQL.
|
|
361
|
-
async with connect(supports_core_api=False) as conn:
|
|
362
|
-
storage = await thread_storage(conn, thread_id)
|
|
357
|
+
storage = await Threads.storage(conn, thread_id)
|
|
363
358
|
return ApiResponse({"thread_id": thread_id, **storage})
|
|
364
359
|
|
|
365
360
|
|
langgraph_api/cache.py
CHANGED
|
@@ -26,12 +26,17 @@ else:
|
|
|
26
26
|
|
|
27
27
|
async def _cache_set(key: str, value: bytes, ttl: timedelta | None = None) -> None:
|
|
28
28
|
ttl = _clamp_ttl(ttl)
|
|
29
|
-
_CACHE.set(key, value)
|
|
29
|
+
_CACHE.set(key, value, ttl=ttl.total_seconds())
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
|
|
34
34
|
_SWR_KEY_PREFIX = "__lg_swr__:"
|
|
35
|
+
# Deliberately a disjoint prefix (diverges from _SWR_KEY_PREFIX at the first
|
|
36
|
+
# character after the shared "__lg_swr" stem), not a suffix appended to a
|
|
37
|
+
# cache_key: a caller using swr(key="foo:marker") would otherwise produce a
|
|
38
|
+
# cache_key identical to the marker key for swr(key="foo"), clobbering it.
|
|
39
|
+
_SWR_MARKER_KEY_PREFIX = "__lg_swr_marker__:"
|
|
35
40
|
_SWR_SCHEMA_VERSION = 1
|
|
36
41
|
_DELTA_ZERO = timedelta(0)
|
|
37
42
|
_UNSET: Any = object()
|
|
@@ -127,6 +132,31 @@ class _SWREntry(TypedDict):
|
|
|
127
132
|
stored_at_ms: int
|
|
128
133
|
|
|
129
134
|
|
|
135
|
+
class _SWRMarker(TypedDict):
|
|
136
|
+
"""A tiny, payload-free record of when a key was last written.
|
|
137
|
+
|
|
138
|
+
Stored under a separate key with a much longer ttl than the full entry,
|
|
139
|
+
so swr() can still tell "this existed and is now expired" from "never
|
|
140
|
+
written" long after the (possibly large) payload itself has expired.
|
|
141
|
+
|
|
142
|
+
Two accepted trade-offs from using a second key, deliberately not
|
|
143
|
+
engineered around:
|
|
144
|
+
- A write's ttl=max_age reflects that call's own max_age, not the
|
|
145
|
+
largest max_age any caller has ever used for this key. A key read
|
|
146
|
+
with different max_age values across calls can see an earlier,
|
|
147
|
+
shorter-lived write evict the entry before a later call's own window
|
|
148
|
+
would have, forcing an extra reload. The reload still returns a
|
|
149
|
+
correct value; only cache-hit efficiency is affected.
|
|
150
|
+
- Every swr() key now costs two slots in the in-memory backend's
|
|
151
|
+
max_size=1000 LRU instead of one. swr() is scoped to moderate-
|
|
152
|
+
cardinality use (auth/metadata lookups per its own docstring), so this
|
|
153
|
+
is judged an acceptable capacity cost for bounding payload memory.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
v: int
|
|
157
|
+
stored_at_ms: int
|
|
158
|
+
|
|
159
|
+
|
|
130
160
|
@dataclass(slots=True)
|
|
131
161
|
class _SWRState:
|
|
132
162
|
task: asyncio.Task[Any] | None = None
|
|
@@ -158,10 +188,15 @@ async def swr(
|
|
|
158
188
|
Defaults to ``timedelta(0)`` so every access triggers a background
|
|
159
189
|
revalidate while still returning the cached value instantly. Values
|
|
160
190
|
above :data:`MAX_CACHE_TTL` are clamped to the backend maximum.
|
|
161
|
-
max_age: Total lifetime of a cached entry
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
backend
|
|
191
|
+
max_age: Total lifetime of a cached entry before it's considered
|
|
192
|
+
expired rather than stale. After this, the next access blocks on
|
|
193
|
+
the loader. Defaults to :data:`MAX_CACHE_TTL` (24 h by default).
|
|
194
|
+
Values above :data:`MAX_CACHE_TTL` are clamped to the backend
|
|
195
|
+
maximum. The full entry is physically stored for `max_age`, so a
|
|
196
|
+
short `max_age` doesn't pin a large payload in the backend for
|
|
197
|
+
longer than it's needed; a small marker without the payload is
|
|
198
|
+
kept for the backend's max TTL so `swr` can still classify a
|
|
199
|
+
later access as `"expired"` instead of missing it entirely.
|
|
165
200
|
model: Optional Pydantic model class. When provided, values are
|
|
166
201
|
serialized via ``model_dump(mode="json")`` before storage and
|
|
167
202
|
deserialized via ``model.model_validate()`` on read.
|
|
@@ -179,7 +214,7 @@ async def swr(
|
|
|
179
214
|
"""
|
|
180
215
|
resolved_fresh_for = min(fresh_for, MAX_CACHE_TTL)
|
|
181
216
|
resolved_max_age = _resolve_swr_max_age(max_age)
|
|
182
|
-
fresh_for_ms, max_age_ms
|
|
217
|
+
fresh_for_ms, max_age_ms = _validate_swr_windows(
|
|
183
218
|
resolved_fresh_for, resolved_max_age
|
|
184
219
|
)
|
|
185
220
|
cache_key = _swr_cache_key(key)
|
|
@@ -216,8 +251,11 @@ async def swr(
|
|
|
216
251
|
|
|
217
252
|
entry = _parse_swr_entry(await cache_get(cache_key))
|
|
218
253
|
if entry is None:
|
|
254
|
+
# The full entry is gone, but a marker surviving past the entry's own
|
|
255
|
+
# ttl means it existed before and is now expired, not merely missing.
|
|
256
|
+
marker = _parse_swr_marker(await cache_get(_swr_marker_key(cache_key)))
|
|
219
257
|
raw = await _await_swr_load(cache_key, json_loader, resolved_max_age)
|
|
220
|
-
return _result(_deserialize(raw), "miss")
|
|
258
|
+
return _result(_deserialize(raw), "expired" if marker is not None else "miss")
|
|
221
259
|
|
|
222
260
|
age_ms = max(0, _now_ms() - entry["stored_at_ms"])
|
|
223
261
|
cached_value = _deserialize(entry["value"])
|
|
@@ -252,9 +290,11 @@ def _swr_cache_key(key: str) -> str:
|
|
|
252
290
|
return f"{_SWR_KEY_PREFIX}{key}"
|
|
253
291
|
|
|
254
292
|
|
|
255
|
-
def
|
|
256
|
-
|
|
257
|
-
|
|
293
|
+
def _swr_marker_key(cache_key: str) -> str:
|
|
294
|
+
return _SWR_MARKER_KEY_PREFIX + cache_key.removeprefix(_SWR_KEY_PREFIX)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _validate_swr_windows(fresh_for: timedelta, max_age: timedelta) -> tuple[int, int]:
|
|
258
298
|
if fresh_for < _DELTA_ZERO:
|
|
259
299
|
raise ValueError("fresh_for must be >= 0")
|
|
260
300
|
if max_age <= _DELTA_ZERO:
|
|
@@ -265,7 +305,6 @@ def _validate_swr_windows(
|
|
|
265
305
|
return (
|
|
266
306
|
int(fresh_for.total_seconds() * 1000),
|
|
267
307
|
int(max_age.total_seconds() * 1000),
|
|
268
|
-
max_age,
|
|
269
308
|
)
|
|
270
309
|
|
|
271
310
|
|
|
@@ -291,6 +330,18 @@ def _parse_swr_entry(value: Any) -> _SWREntry | None:
|
|
|
291
330
|
}
|
|
292
331
|
|
|
293
332
|
|
|
333
|
+
def _parse_swr_marker(value: Any) -> _SWRMarker | None:
|
|
334
|
+
if not isinstance(value, dict):
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
version = value.get("v")
|
|
338
|
+
stored_at_ms = value.get("stored_at_ms")
|
|
339
|
+
if version != _SWR_SCHEMA_VERSION or not isinstance(stored_at_ms, int):
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
return {"v": _SWR_SCHEMA_VERSION, "stored_at_ms": stored_at_ms}
|
|
343
|
+
|
|
344
|
+
|
|
294
345
|
def _acquire_swr_state(cache_key: str) -> _SWRState:
|
|
295
346
|
state = _SWR_STATES.get(cache_key)
|
|
296
347
|
if state is None:
|
|
@@ -315,34 +366,41 @@ def _maybe_cleanup_swr_state(cache_key: str, state: _SWRState) -> None:
|
|
|
315
366
|
_SWR_STATES.pop(cache_key, None)
|
|
316
367
|
|
|
317
368
|
|
|
318
|
-
async def _cache_swr_entry(cache_key: str, value: Any,
|
|
369
|
+
async def _cache_swr_entry(cache_key: str, value: Any, max_age: timedelta) -> None:
|
|
370
|
+
stored_at_ms = _now_ms()
|
|
319
371
|
entry: _SWREntry = {
|
|
320
372
|
"v": _SWR_SCHEMA_VERSION,
|
|
321
373
|
"value": value,
|
|
322
|
-
"stored_at_ms":
|
|
374
|
+
"stored_at_ms": stored_at_ms,
|
|
323
375
|
}
|
|
324
|
-
|
|
376
|
+
marker: _SWRMarker = {"v": _SWR_SCHEMA_VERSION, "stored_at_ms": stored_at_ms}
|
|
377
|
+
# The full payload expires at max_age, so a short max_age doesn't pin a
|
|
378
|
+
# large value in the backend longer than it's needed. The marker carries
|
|
379
|
+
# no payload, so it's cheap to keep around for the backend's own max ttl
|
|
380
|
+
# regardless of max_age - see swr()'s cache_get(cache_key) is None branch.
|
|
381
|
+
await cache_set(cache_key, entry, ttl=max_age)
|
|
382
|
+
await cache_set(_swr_marker_key(cache_key), marker)
|
|
325
383
|
|
|
326
384
|
|
|
327
|
-
async def _write_swr_value(cache_key: str, value: Any,
|
|
385
|
+
async def _write_swr_value(cache_key: str, value: Any, max_age: timedelta) -> None:
|
|
328
386
|
state = _acquire_swr_state(cache_key)
|
|
329
387
|
try:
|
|
330
388
|
async with state.write_lock:
|
|
331
389
|
state.write_epoch += 1
|
|
332
|
-
await _cache_swr_entry(cache_key, value,
|
|
390
|
+
await _cache_swr_entry(cache_key, value, max_age)
|
|
333
391
|
finally:
|
|
334
392
|
_release_swr_state(cache_key, state)
|
|
335
393
|
|
|
336
394
|
|
|
337
395
|
async def _store_loaded_swr_value(
|
|
338
|
-
cache_key: str, value: Any,
|
|
396
|
+
cache_key: str, value: Any, max_age: timedelta, *, start_epoch: int
|
|
339
397
|
) -> bool:
|
|
340
398
|
state = _acquire_swr_state(cache_key)
|
|
341
399
|
try:
|
|
342
400
|
async with state.write_lock:
|
|
343
401
|
if start_epoch != state.write_epoch:
|
|
344
402
|
return False
|
|
345
|
-
await _cache_swr_entry(cache_key, value,
|
|
403
|
+
await _cache_swr_entry(cache_key, value, max_age)
|
|
346
404
|
return True
|
|
347
405
|
finally:
|
|
348
406
|
_release_swr_state(cache_key, state)
|
|
@@ -351,12 +409,14 @@ async def _store_loaded_swr_value(
|
|
|
351
409
|
async def _load_and_store_swr_value(
|
|
352
410
|
cache_key: str,
|
|
353
411
|
loader: Callable[[], Awaitable[T]],
|
|
354
|
-
|
|
412
|
+
max_age: timedelta,
|
|
355
413
|
*,
|
|
356
414
|
start_epoch: int,
|
|
357
415
|
) -> T:
|
|
358
416
|
value = await loader()
|
|
359
|
-
if await _store_loaded_swr_value(
|
|
417
|
+
if await _store_loaded_swr_value(
|
|
418
|
+
cache_key, value, max_age, start_epoch=start_epoch
|
|
419
|
+
):
|
|
360
420
|
return value
|
|
361
421
|
|
|
362
422
|
entry = _parse_swr_entry(await cache_get(cache_key))
|
|
@@ -368,7 +428,7 @@ async def _load_and_store_swr_value(
|
|
|
368
428
|
def _ensure_swr_load_task(
|
|
369
429
|
cache_key: str,
|
|
370
430
|
loader: Callable[[], Awaitable[T]],
|
|
371
|
-
|
|
431
|
+
max_age: timedelta,
|
|
372
432
|
*,
|
|
373
433
|
log_errors: bool,
|
|
374
434
|
) -> asyncio.Task[T]:
|
|
@@ -380,7 +440,7 @@ def _ensure_swr_load_task(
|
|
|
380
440
|
|
|
381
441
|
task = asyncio.create_task(
|
|
382
442
|
_load_and_store_swr_value(
|
|
383
|
-
cache_key, loader,
|
|
443
|
+
cache_key, loader, max_age, start_epoch=state.write_epoch
|
|
384
444
|
)
|
|
385
445
|
)
|
|
386
446
|
state.task = task
|
|
@@ -407,12 +467,12 @@ def _ensure_swr_load_task(
|
|
|
407
467
|
|
|
408
468
|
|
|
409
469
|
async def _await_swr_load(
|
|
410
|
-
cache_key: str, loader: Callable[[], Awaitable[T]],
|
|
470
|
+
cache_key: str, loader: Callable[[], Awaitable[T]], max_age: timedelta
|
|
411
471
|
) -> T:
|
|
412
|
-
return await _ensure_swr_load_task(cache_key, loader,
|
|
472
|
+
return await _ensure_swr_load_task(cache_key, loader, max_age, log_errors=False)
|
|
413
473
|
|
|
414
474
|
|
|
415
475
|
def _start_swr_refresh(
|
|
416
|
-
cache_key: str, loader: Callable[[], Awaitable[Any]],
|
|
476
|
+
cache_key: str, loader: Callable[[], Awaitable[Any]], max_age: timedelta
|
|
417
477
|
) -> None:
|
|
418
|
-
_ensure_swr_load_task(cache_key, loader,
|
|
478
|
+
_ensure_swr_load_task(cache_key, loader, max_age, log_errors=True)
|
langgraph_api/config/__init__.py
CHANGED
|
@@ -23,6 +23,11 @@ from langgraph_api.config.schemas import (
|
|
|
23
23
|
WebhooksConfig,
|
|
24
24
|
webhooks_validator,
|
|
25
25
|
)
|
|
26
|
+
from langgraph_api.feature_flags import (
|
|
27
|
+
IS_POSTGRES_BACKEND,
|
|
28
|
+
OMIT_PENDING_SENDS,
|
|
29
|
+
PREFER_GRPC_CHECKPOINTER,
|
|
30
|
+
)
|
|
26
31
|
|
|
27
32
|
if TYPE_CHECKING:
|
|
28
33
|
from collections.abc import Callable
|
|
@@ -50,6 +55,21 @@ STATS_INTERVAL_SECS = env("STATS_INTERVAL_SECS", cast=int, default=60)
|
|
|
50
55
|
DATABASE_URI: str | None = env(
|
|
51
56
|
"DATABASE_URI", cast=str, default=getenv("POSTGRES_URI", None)
|
|
52
57
|
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_langgraph_without_database(
|
|
61
|
+
*, langgraph_supported: bool, is_postgres: bool
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Reject langgraph < 0.5 when the Postgres runtime has no DATABASE_URI."""
|
|
64
|
+
# langgraph < 0.5 always uses the Python checkpointer, which needs a direct
|
|
65
|
+
# connection (see feature_flags.PREFER_GRPC_CHECKPOINTER).
|
|
66
|
+
if is_postgres and not langgraph_supported and not DATABASE_URI:
|
|
67
|
+
raise ValueError("langgraph >= 0.5 is required when core-api owns the database")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_validate_langgraph_without_database(
|
|
71
|
+
langgraph_supported=OMIT_PENDING_SENDS, is_postgres=IS_POSTGRES_BACKEND
|
|
72
|
+
)
|
|
53
73
|
AGENT_POSTGRES_IAM_AUTH_PROVIDER = env(
|
|
54
74
|
"AGENT_POSTGRES_IAM_AUTH_PROVIDER", cast=str, default=""
|
|
55
75
|
)
|
|
@@ -155,6 +175,28 @@ LANGGRAPH_AES_JSON_KEYS: frozenset[str] | None = env(
|
|
|
155
175
|
"LANGGRAPH_AES_JSON_KEYS", default=None, cast=_get_aes_json_keys
|
|
156
176
|
)
|
|
157
177
|
|
|
178
|
+
|
|
179
|
+
def _validate_aes_rotation(
|
|
180
|
+
prefer_grpc_checkpointer: bool, *, is_postgres: bool
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Reject AES key rotation when Python owns checkpoints, as core-server does."""
|
|
183
|
+
# Uses the derived PREFER_GRPC_CHECKPOINTER (false on langgraph < 0.5), while
|
|
184
|
+
# core-server checks the raw env var. A missing LANGGRAPH_AES_KEY is left to
|
|
185
|
+
# core-server, which reports it first.
|
|
186
|
+
rotation_keys = getenv("LANGGRAPH_AES_LEGACY_KEY") or getenv(
|
|
187
|
+
"LANGGRAPH_AES_FALLBACK_KEYS"
|
|
188
|
+
)
|
|
189
|
+
if (
|
|
190
|
+
is_postgres
|
|
191
|
+
and not prefer_grpc_checkpointer
|
|
192
|
+
and rotation_keys
|
|
193
|
+
and LANGGRAPH_AES_KEY
|
|
194
|
+
):
|
|
195
|
+
raise ValueError("AES key rotation requires PREFER_GRPC_CHECKPOINTER=true")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_validate_aes_rotation(PREFER_GRPC_CHECKPOINTER, is_postgres=IS_POSTGRES_BACKEND)
|
|
199
|
+
|
|
158
200
|
# redis
|
|
159
201
|
# Not in public docs: infrastructure, set by platform
|
|
160
202
|
REDIS_URI = env("REDIS_URI_CUSTOM", cast=str, default="") or env("REDIS_URI", cast=str)
|
|
@@ -275,9 +317,6 @@ WEBHOOKS_ENABLED = HTTP_CONFIG and HTTP_CONFIG.get("disable_webhooks")
|
|
|
275
317
|
STORE_CONFIG = env(
|
|
276
318
|
"LANGGRAPH_STORE", cast=_parse.parse_schema(StoreConfig), default=None
|
|
277
319
|
)
|
|
278
|
-
LANGGRAPH_STORE_BACKEND = env("LANGGRAPH_STORE_BACKEND", cast=str, default="python")
|
|
279
|
-
USE_GRPC_STORE = LANGGRAPH_STORE_BACKEND == "grpc"
|
|
280
|
-
|
|
281
320
|
# When true, the Go core-server owns Postgres schema setup (DB migrations +
|
|
282
321
|
# vector index)
|
|
283
322
|
DB_MIGRATION_BY_CORE_API = env("DB_MIGRATION_BY_CORE_API", cast=bool, default=False)
|
|
@@ -469,6 +508,8 @@ FF_USE_REDIS_QUEUE = env("FF_USE_REDIS_QUEUE", cast=bool, default=True)
|
|
|
469
508
|
|
|
470
509
|
# Internal flag intended for testing only
|
|
471
510
|
CRON_SCHEDULER_SLEEP_TIME = env("CRON_SCHEDULER_SLEEP_TIME", cast=int, default=5)
|
|
511
|
+
# Not in public docs: max due crons each scheduler claims per poll; <= 0 means no limit
|
|
512
|
+
CRON_SCHEDULER_BATCH_SIZE = env("CRON_SCHEDULER_BATCH_SIZE", cast=int, default=-1)
|
|
472
513
|
|
|
473
514
|
|
|
474
515
|
# auth
|
|
@@ -718,6 +759,7 @@ __all__ = [
|
|
|
718
759
|
"CHECKPOINT_MAX_BATCH_SIZE",
|
|
719
760
|
"CORS_ALLOW_ORIGINS",
|
|
720
761
|
"CORS_CONFIG",
|
|
762
|
+
"CRON_SCHEDULER_BATCH_SIZE",
|
|
721
763
|
"CRON_SCHEDULER_SLEEP_TIME",
|
|
722
764
|
"DATABASE_URI",
|
|
723
765
|
"DATADOG_METRICS_ENABLED",
|
|
@@ -756,7 +798,6 @@ __all__ = [
|
|
|
756
798
|
"LANGGRAPH_METRICS_ENDPOINT",
|
|
757
799
|
"LANGGRAPH_METRICS_EXPORT_INTERVAL_MS",
|
|
758
800
|
"LANGGRAPH_POSTGRES_EXTENSIONS",
|
|
759
|
-
"LANGGRAPH_STORE_BACKEND",
|
|
760
801
|
"LANGSMITH_API_KEY",
|
|
761
802
|
"LANGSMITH_AUTH_ENDPOINT",
|
|
762
803
|
"LANGSMITH_AUTH_VERIFY_TENANT_ID",
|
|
@@ -804,7 +845,6 @@ __all__ = [
|
|
|
804
845
|
"USES_STORE_TTL",
|
|
805
846
|
"USES_THREAD_TTL",
|
|
806
847
|
"USE_CUSTOM_CHECKPOINTER",
|
|
807
|
-
"USE_GRPC_STORE",
|
|
808
848
|
"AuthConfig",
|
|
809
849
|
"CheckpointerConfig",
|
|
810
850
|
"CorsConfig",
|
langgraph_api/config/schemas.py
CHANGED
|
@@ -150,7 +150,7 @@ class IndexConfig(TypedDict, total=False):
|
|
|
150
150
|
|
|
151
151
|
dims: int
|
|
152
152
|
"""Number of dimensions in the embedding vectors.
|
|
153
|
-
|
|
153
|
+
|
|
154
154
|
Common embedding models have the following dimensions:
|
|
155
155
|
- OpenAI text-embedding-3-large: 256, 1024, or 3072
|
|
156
156
|
- OpenAI text-embedding-3-small: 512 or 1536
|
|
@@ -164,13 +164,13 @@ class IndexConfig(TypedDict, total=False):
|
|
|
164
164
|
embed: str
|
|
165
165
|
"""Either a path to an embedding model (./path/to/file.py:embedding_model)
|
|
166
166
|
or a name of an embedding model (openai:text-embedding-3-small)
|
|
167
|
-
|
|
167
|
+
|
|
168
168
|
Note: LangChain is required to use the model format specification.
|
|
169
169
|
"""
|
|
170
170
|
|
|
171
171
|
fields: list[str] | None
|
|
172
172
|
"""Fields to extract text from for embedding generation.
|
|
173
|
-
|
|
173
|
+
|
|
174
174
|
Defaults to the root ["$"], which embeds the json object as a whole.
|
|
175
175
|
"""
|
|
176
176
|
|
|
@@ -180,21 +180,21 @@ class TTLConfig(TypedDict, total=False):
|
|
|
180
180
|
|
|
181
181
|
refresh_on_read: bool
|
|
182
182
|
"""Default behavior for refreshing TTLs on read operations (GET and SEARCH).
|
|
183
|
-
|
|
183
|
+
|
|
184
184
|
If True, TTLs will be refreshed on read operations (get/search) by default.
|
|
185
185
|
This can be overridden per-operation by explicitly setting refresh_ttl.
|
|
186
186
|
Defaults to True if not configured.
|
|
187
187
|
"""
|
|
188
188
|
default_ttl: float | None
|
|
189
189
|
"""Default TTL (time-to-live) in minutes for new items.
|
|
190
|
-
|
|
190
|
+
|
|
191
191
|
If provided, new items will expire after this many minutes after their last access.
|
|
192
192
|
The expiration timer refreshes on both read and write operations.
|
|
193
193
|
Defaults to None (no expiration).
|
|
194
194
|
"""
|
|
195
195
|
sweep_interval_minutes: int | None
|
|
196
196
|
"""Interval in minutes between TTL sweep operations.
|
|
197
|
-
|
|
197
|
+
|
|
198
198
|
If provided, the store will periodically delete expired items based on TTL.
|
|
199
199
|
Defaults to None (no sweeping).
|
|
200
200
|
"""
|
|
@@ -223,11 +223,11 @@ class SerdeConfig(TypedDict, total=False):
|
|
|
223
223
|
|
|
224
224
|
allowed_json_modules: list[list[str]] | Literal[True] | None
|
|
225
225
|
"""Optional. List of allowed python modules to de-serialize custom objects from.
|
|
226
|
-
|
|
226
|
+
|
|
227
227
|
If provided, only the specified modules will be allowed to be deserialized.
|
|
228
228
|
If omitted, no modules are allowed, and the object returned will simply be a json object OR
|
|
229
229
|
a deserialized langchain object.
|
|
230
|
-
|
|
230
|
+
|
|
231
231
|
Example:
|
|
232
232
|
{...
|
|
233
233
|
"serde": {
|
|
@@ -245,11 +245,11 @@ class SerdeConfig(TypedDict, total=False):
|
|
|
245
245
|
"allowed_json_modules": true
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
-
|
|
248
|
+
|
|
249
249
|
"""
|
|
250
250
|
pickle_fallback: bool
|
|
251
251
|
"""Optional. Whether to allow pickling as a fallback for deserialization.
|
|
252
|
-
|
|
252
|
+
|
|
253
253
|
If True, pickling will be allowed as a fallback for deserialization.
|
|
254
254
|
If False, pickling will not be allowed as a fallback for deserialization.
|
|
255
255
|
Defaults to True if not configured."""
|
|
@@ -261,7 +261,7 @@ class DefaultCheckpointerConfig(TypedDict):
|
|
|
261
261
|
backend: Literal["default"]
|
|
262
262
|
ttl: NotRequired[ThreadTTLConfig | None]
|
|
263
263
|
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
|
264
|
-
|
|
264
|
+
|
|
265
265
|
If provided, the checkpointer will apply TTL settings according to the configuration.
|
|
266
266
|
If omitted, no TTL behavior is configured.
|
|
267
267
|
"""
|
|
@@ -284,7 +284,7 @@ class CustomCheckpointerConfig(TypedDict):
|
|
|
284
284
|
"""
|
|
285
285
|
ttl: NotRequired[ThreadTTLConfig | None]
|
|
286
286
|
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
|
287
|
-
|
|
287
|
+
|
|
288
288
|
If provided, the checkpointer will apply TTL settings according to the configuration.
|
|
289
289
|
If omitted, no TTL behavior is configured.
|
|
290
290
|
"""
|
langgraph_api/cron_scheduler.py
CHANGED
|
@@ -25,6 +25,7 @@ else:
|
|
|
25
25
|
logger = structlog.stdlib.get_logger(__name__)
|
|
26
26
|
|
|
27
27
|
SLEEP_TIME = config.CRON_SCHEDULER_SLEEP_TIME
|
|
28
|
+
BATCH_SIZE = config.CRON_SCHEDULER_BATCH_SIZE
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def get_metadata_from_payload(
|
|
@@ -47,13 +48,19 @@ def get_metadata_from_payload(
|
|
|
47
48
|
return {**cron_metadata, **existing_metadata}
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def batch_was_full(claimed: int) -> bool:
|
|
52
|
+
return BATCH_SIZE > 0 and claimed >= BATCH_SIZE
|
|
53
|
+
|
|
54
|
+
|
|
50
55
|
@retry_db
|
|
51
56
|
async def cron_scheduler():
|
|
52
57
|
logger.info("Starting cron scheduler")
|
|
53
58
|
while True:
|
|
54
59
|
try:
|
|
60
|
+
claimed = 0
|
|
55
61
|
async with connect() as conn:
|
|
56
62
|
async for cron, enc_ctx in Crons.next(conn):
|
|
63
|
+
claimed += 1
|
|
57
64
|
on_run_completed = cron.get("on_run_completed")
|
|
58
65
|
|
|
59
66
|
run_payload = cron["payload"]
|
|
@@ -75,6 +82,12 @@ async def cron_scheduler():
|
|
|
75
82
|
):
|
|
76
83
|
logger.debug(f"Scheduling cron run {cron}")
|
|
77
84
|
try:
|
|
85
|
+
cron_id = None
|
|
86
|
+
if run_payload.get("context"):
|
|
87
|
+
# Cron persistence and set_auth_ctx_for_run write these into configurable, which create_valid_run rejects next to context.
|
|
88
|
+
configurable = run_payload["config"]["configurable"]
|
|
89
|
+
cron_id = configurable.pop("cron_id", None)
|
|
90
|
+
configurable.pop("langgraph_auth_user", None)
|
|
78
91
|
run = await create_valid_run(
|
|
79
92
|
conn,
|
|
80
93
|
thread_id=(
|
|
@@ -84,6 +97,7 @@ async def cron_scheduler():
|
|
|
84
97
|
),
|
|
85
98
|
payload=run_payload,
|
|
86
99
|
headers={},
|
|
100
|
+
cron_id=cron_id,
|
|
87
101
|
)
|
|
88
102
|
if not run:
|
|
89
103
|
logger.error(
|
|
@@ -112,7 +126,8 @@ async def cron_scheduler():
|
|
|
112
126
|
conn, cron["cron_id"], next_run_date
|
|
113
127
|
)
|
|
114
128
|
|
|
115
|
-
|
|
129
|
+
if not batch_was_full(claimed):
|
|
130
|
+
await asyncio.sleep(SLEEP_TIME)
|
|
116
131
|
except asyncio.CancelledError:
|
|
117
132
|
raise
|
|
118
133
|
except Exception:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
langgraph_api/feature_flags.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import re
|
|
3
3
|
|
|
4
|
+
import structlog
|
|
4
5
|
from langgraph.version import __version__
|
|
5
6
|
|
|
7
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
8
|
+
|
|
6
9
|
# Only gate features on the major.minor version; Lets you ignore the rc/alpha/etc. releases anyway
|
|
7
10
|
LANGGRAPH_PY_MINOR = tuple(map(int, __version__.split(".")[:2]))
|
|
8
11
|
|
|
@@ -29,14 +32,42 @@ FF_V2_EVENT_STREAMING = os.getenv("FF_V2_EVENT_STREAMING", "true").lower() in (
|
|
|
29
32
|
"yes",
|
|
30
33
|
)
|
|
31
34
|
|
|
35
|
+
|
|
36
|
+
def _resolve_prefer_grpc_checkpointer(
|
|
37
|
+
env_value: str | None, *, langgraph_supported: bool, is_postgres: bool
|
|
38
|
+
) -> bool:
|
|
39
|
+
enabled = ("true" if env_value is None else env_value).lower() in (
|
|
40
|
+
"true",
|
|
41
|
+
"1",
|
|
42
|
+
"yes",
|
|
43
|
+
)
|
|
44
|
+
if langgraph_supported:
|
|
45
|
+
return enabled
|
|
46
|
+
if is_postgres:
|
|
47
|
+
ignored = (
|
|
48
|
+
"Ignoring PREFER_GRPC_CHECKPOINTER=true. " if env_value and enabled else ""
|
|
49
|
+
)
|
|
50
|
+
logger.warning(
|
|
51
|
+
f"{ignored}Using the Python checkpointer because the Go checkpointer "
|
|
52
|
+
"requires langgraph>=0.5. Please upgrade langgraph: support for "
|
|
53
|
+
"langgraph<0.5 will be removed in a future release.",
|
|
54
|
+
langgraph_version=__version__,
|
|
55
|
+
)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
32
59
|
# When true on the postgres runtime, use ``GrpcCheckpointer``.
|
|
33
60
|
# Inmem edition is unaffected (``IS_POSTGRES_BACKEND`` is false there).
|
|
34
61
|
# Custom checkpointer (``backend=custom`` in ``LANGGRAPH_CHECKPOINTER``) and
|
|
35
62
|
# Mongo (``backend=mongo``) is also unaffected.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
# langgraph < 0.5 keeps Send() packets in checkpoint["pending_sends"], which only
|
|
64
|
+
# the Python checkpointer rebuilds on load; the Go checkpointer drops them. Keep
|
|
65
|
+
# < 0.5 on the Python checkpointer. Before removing it, raise the minimum
|
|
66
|
+
# langgraph to >= 0.5.
|
|
67
|
+
PREFER_GRPC_CHECKPOINTER = _resolve_prefer_grpc_checkpointer(
|
|
68
|
+
os.getenv("PREFER_GRPC_CHECKPOINTER"),
|
|
69
|
+
langgraph_supported=OMIT_PENDING_SENDS,
|
|
70
|
+
is_postgres=IS_POSTGRES_BACKEND,
|
|
40
71
|
)
|
|
41
72
|
|
|
42
73
|
# In langgraph <= 1.0.3, we automatically subscribed to updates stream events to surface interrupts. In langgraph 1.0.4 we include interrupts in values events (which we are automatically subscribed to), so we no longer need to implicitly subscribe to updates stream events
|
langgraph_api/grpc/ops/crons.py
CHANGED
|
@@ -5,7 +5,6 @@ from datetime import UTC, datetime
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Literal
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
|
-
from google.protobuf.empty_pb2 import Empty # ty: ignore[unresolved-import]
|
|
9
8
|
from langgraph_grpc_common.conversion.config import config_from_proto, config_to_proto
|
|
10
9
|
from langgraph_grpc_common.proto import core_api_pb2 as pb
|
|
11
10
|
from langgraph_grpc_common.proto import (
|
|
@@ -16,6 +15,7 @@ from langgraph_grpc_common.proto import (
|
|
|
16
15
|
)
|
|
17
16
|
from langgraph_sdk import Auth
|
|
18
17
|
|
|
18
|
+
from langgraph_api import config
|
|
19
19
|
from langgraph_api.graph import SYSTEM_ASSISTANT_IDS, get_assistant_id
|
|
20
20
|
from langgraph_api.grpc.client import get_shared_client
|
|
21
21
|
from langgraph_api.grpc.ops import (
|
|
@@ -502,6 +502,7 @@ class Crons(Authenticated):
|
|
|
502
502
|
@staticmethod
|
|
503
503
|
async def next(
|
|
504
504
|
conn, # Not used in gRPC implementation
|
|
505
|
+
limit: int = config.CRON_SCHEDULER_BATCH_SIZE,
|
|
505
506
|
) -> AsyncIterator[tuple[Cron, dict | None]]:
|
|
506
507
|
"""Get next crons ready to run via gRPC (internal API, no auth filters).
|
|
507
508
|
|
|
@@ -512,7 +513,7 @@ class Crons(Authenticated):
|
|
|
512
513
|
"""
|
|
513
514
|
|
|
514
515
|
client = await get_shared_client()
|
|
515
|
-
response = await client.crons.Next(
|
|
516
|
+
response = await client.crons.Next(pb.NextCronsRequest(limit=limit))
|
|
516
517
|
|
|
517
518
|
for cron_with_now in response.crons:
|
|
518
519
|
cron = proto_to_cron(cron_with_now.cron)
|
langgraph_api/grpc/ops/runs.py
CHANGED
|
@@ -386,6 +386,9 @@ def proto_to_run(proto_run: pb.Run) -> Run:
|
|
|
386
386
|
"langsmith_session_name": proto_run.langsmith_session_name
|
|
387
387
|
if proto_run.HasField("langsmith_session_name")
|
|
388
388
|
else None,
|
|
389
|
+
"cancel_reason": proto_run.cancel_reason
|
|
390
|
+
if proto_run.HasField("cancel_reason")
|
|
391
|
+
else None,
|
|
389
392
|
}
|
|
390
393
|
|
|
391
394
|
|