langgraph-runtime-inmem 0.35.4__tar.gz → 0.36.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/Makefile +1 -2
  2. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/PKG-INFO +1 -1
  3. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/__init__.py +1 -1
  4. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/checkpoint.py +47 -0
  5. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/database.py +24 -3
  6. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/inmem_stream.py +2 -0
  7. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/ops.py +61 -43
  8. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/.gitignore +0 -0
  9. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/README.md +0 -0
  10. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/_persistence.py +0 -0
  11. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/lifespan.py +0 -0
  12. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/metrics.py +0 -0
  13. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/queue.py +0 -0
  14. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/retry.py +0 -0
  15. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/routes.py +0 -0
  16. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/store.py +0 -0
  17. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/pyproject.toml +0 -0
  18. {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/uv.lock +0 -0
@@ -1,4 +1,4 @@
1
- .PHONY: lint format check-version check-base-imports
1
+ .PHONY: lint format check-version check-base-imports bump-version
2
2
 
3
3
  # lint commands
4
4
 
@@ -15,6 +15,5 @@ VERSION_KIND ?= patch
15
15
  bump-version:
16
16
  uv run --with hatch hatch version $(VERSION_KIND)
17
17
 
18
-
19
18
  check-base-imports:
20
19
  DATABASE_URI="foo" REDIS_URI="bar" uv run python -c "from langgraph_runtime_inmem import *"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-runtime-inmem
3
- Version: 0.35.4
3
+ Version: 0.36.0
4
4
  Summary: Inmem implementation for the LangGraph API server.
5
5
  Author-email: Will Fu-Hinthorn <will@langchain.dev>
6
6
  License: Elastic-2.0
@@ -10,7 +10,7 @@ from langgraph_runtime_inmem import (
10
10
  store,
11
11
  )
12
12
 
13
- __version__ = "0.35.4"
13
+ __version__ = "0.36.0"
14
14
  __all__ = [
15
15
  "ops",
16
16
  "database",
@@ -299,3 +299,50 @@ def Checkpointer(*args, unpack_hook=None, **kwargs):
299
299
 
300
300
 
301
301
  __all__ = ["Checkpointer"]
302
+
303
+
304
+ def _nbytes(value: Any) -> int:
305
+ if isinstance(value, bytes | bytearray | memoryview):
306
+ return len(value)
307
+ if isinstance(value, tuple | list):
308
+ return sum(_nbytes(item) for item in value)
309
+ return 0
310
+
311
+
312
+ async def thread_storage(conn: Any, thread_id: str) -> dict[str, Any]:
313
+ """Bytes a thread's checkpoints occupy in memory, per table and per channel.
314
+
315
+ Same shape as the postgres runtime's report, so one endpoint serves both.
316
+ """
317
+ saver = Checkpointer()
318
+ by_channel: dict[str, dict[str, int]] = {}
319
+ for (tid, _ns, channel, _version), blob in list(saver.blobs.items()):
320
+ if tid != thread_id:
321
+ continue
322
+ entry = by_channel.setdefault(channel, {"versions": 0, "bytes": 0})
323
+ entry["versions"] += 1
324
+ entry["bytes"] += _nbytes(blob)
325
+ checkpoint_count = checkpoint_bytes = 0
326
+ if thread_id in saver.storage:
327
+ for checkpoints in saver.storage[thread_id].values():
328
+ for stored in checkpoints.values():
329
+ checkpoint_count += 1
330
+ checkpoint_bytes += _nbytes(stored)
331
+ write_count = write_bytes = 0
332
+ for (tid, _ns, _checkpoint_id), writes in list(saver.writes.items()):
333
+ if tid != thread_id:
334
+ continue
335
+ for write in writes.values():
336
+ write_count += 1
337
+ write_bytes += _nbytes(write)
338
+ channel_bytes = sum(entry["bytes"] for entry in by_channel.values())
339
+ return {
340
+ "total_bytes": checkpoint_bytes + channel_bytes + write_bytes,
341
+ "checkpoints": {"count": checkpoint_count, "bytes": checkpoint_bytes},
342
+ "channel_values": {
343
+ "count": sum(entry["versions"] for entry in by_channel.values()),
344
+ "bytes": channel_bytes,
345
+ "by_channel": by_channel,
346
+ },
347
+ "pending_writes": {"count": write_count, "bytes": write_bytes},
348
+ }
@@ -2,9 +2,9 @@ import asyncio
2
2
  import os
3
3
  import uuid
4
4
  from collections import defaultdict
5
- from collections.abc import AsyncIterator
5
+ from collections.abc import AsyncIterator, Awaitable, Callable
6
6
  from contextlib import asynccontextmanager
7
- from datetime import datetime
7
+ from datetime import UTC, datetime
8
8
  from typing import TYPE_CHECKING, Any, NotRequired
9
9
  from uuid import UUID
10
10
 
@@ -185,6 +185,20 @@ async def start_pool() -> None:
185
185
  for k in ["runs", "threads", "assistant_versions", "assistants"]:
186
186
  if not GLOBAL_STORE.get(k):
187
187
  GLOBAL_STORE[k] = []
188
+
189
+ # No worker has started yet, so a run saved as running was cut off by a crash.
190
+ # Left running, it blocks every later run on its thread (Runs.next skips the thread).
191
+ orphaned = [r for r in GLOBAL_STORE["runs"] if r["status"] == "running"]
192
+ now = datetime.now(UTC)
193
+ for run in orphaned:
194
+ run["status"] = "pending"
195
+ run["updated_at"] = now
196
+ if orphaned:
197
+ logger.warning(
198
+ "Retrying runs left running by a previous process that did not shut down cleanly",
199
+ run_ids=[str(r["run_id"]) for r in orphaned],
200
+ )
201
+
188
202
  # Remove agents that were created by the system to avoid removed graphs
189
203
  if GLOBAL_STORE.get("assistants"):
190
204
  for a in GLOBAL_STORE["assistants"]:
@@ -195,8 +209,10 @@ async def start_pool() -> None:
195
209
  for k in ["crons"]:
196
210
  if not GLOBAL_STORE.get(k):
197
211
  GLOBAL_STORE[k] = []
198
- register_persistent_dict(GLOBAL_STORE)
212
+ # Flushed in this order: a crash between the two writes then over-counts an
213
+ # attempt rather than letting a retried run skip one.
199
214
  register_persistent_dict(GLOBAL_RETRY_COUNTER._counters)
215
+ register_persistent_dict(GLOBAL_STORE)
200
216
  await start_stream()
201
217
 
202
218
 
@@ -214,6 +230,11 @@ async def stop_pool() -> None:
214
230
  await stop_stream()
215
231
 
216
232
 
233
+ def dependency_checks() -> dict[str, Callable[[], Awaitable[None]]]:
234
+ """No external data dependencies to probe."""
235
+ return {}
236
+
237
+
217
238
  async def healthcheck(*, check_db: bool = True) -> None:
218
239
  # What could possibly go wrong?
219
240
  pass
@@ -26,6 +26,8 @@ class Message:
26
26
  topic: bytes
27
27
  data: bytes
28
28
  id: bytes | None = None
29
+ # Control messages only: server-generated explanation of an interrupt/rollback
30
+ reason: str | None = None
29
31
 
30
32
 
31
33
  class ContextQueue(asyncio.Queue):
@@ -620,6 +620,7 @@ class Assistants(Authenticated):
620
620
  assistant_id=assistant_id,
621
621
  action="interrupt",
622
622
  ctx=ctx,
623
+ reason=f"assistant {assistant_id} was deleted",
623
624
  )
624
625
 
625
626
  # 4. Delete assistant
@@ -851,15 +852,21 @@ def _patch_interrupt(
851
852
 
852
853
  In v0.4 and v0.5, interrupt_id is a property on the langgraph.types.Interrupt object,
853
854
  so we reconstruct the type in order to access the id, with compatibility for the new
854
- v0.6 interrupt format as well.
855
+ v0.6 interrupt format as well. `response_schema` is optional; older langgraph releases
856
+ do not set it.
855
857
  """
856
858
  if USE_NEW_INTERRUPTS:
859
+ response_schema = (
860
+ interrupt.get("response_schema")
861
+ if isinstance(interrupt, dict)
862
+ else getattr(interrupt, "response_schema", None)
863
+ )
857
864
  interrupt = Interrupt(**interrupt) if isinstance(interrupt, dict) else interrupt
858
865
 
859
- return {
860
- "id": interrupt.id,
861
- "value": interrupt.value,
862
- }
866
+ patched: InterruptSchema = {"id": interrupt.id, "value": interrupt.value}
867
+ if response_schema is not None:
868
+ patched["response_schema"] = response_schema
869
+ return patched
863
870
  else:
864
871
  if isinstance(interrupt, dict):
865
872
  # interrupt_id is a deprecated property on Interrupt and should not be used for initialization
@@ -1236,19 +1243,17 @@ class Threads(Authenticated):
1236
1243
  "updated_at": now,
1237
1244
  "state_updated_at": now,
1238
1245
  "status": status,
1239
- "interrupts": (
1240
- {
1241
- t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
1242
- for t in checkpoint["tasks"]
1243
- if t.get("interrupts")
1244
- }
1245
- if checkpoint
1246
- else {}
1247
- ),
1248
1246
  "error": json_loads(json_dumpb(exception)) if exception else None,
1249
1247
  }
1248
+ # Without a checkpoint there is nothing to replace values and interrupts
1249
+ # with, so keep the ones the thread already has.
1250
1250
  if checkpoint is not None:
1251
1251
  update["values"] = checkpoint["values"]
1252
+ update["interrupts"] = {
1253
+ t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
1254
+ for t in checkpoint["tasks"]
1255
+ if t.get("interrupts")
1256
+ }
1252
1257
  thread.update(update)
1253
1258
 
1254
1259
  @staticmethod
@@ -1312,16 +1317,6 @@ class Threads(Authenticated):
1312
1317
  else:
1313
1318
  base_thread_status = "idle"
1314
1319
 
1315
- interrupts = (
1316
- {
1317
- t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
1318
- for t in checkpoint["tasks"]
1319
- if t.get("interrupts")
1320
- }
1321
- if checkpoint
1322
- else {}
1323
- )
1324
-
1325
1320
  now = datetime.now(UTC)
1326
1321
 
1327
1322
  if run_status == "rollback":
@@ -1341,12 +1336,19 @@ class Threads(Authenticated):
1341
1336
  update: dict = {
1342
1337
  "updated_at": now,
1343
1338
  "state_updated_at": now,
1344
- "interrupts": interrupts,
1345
1339
  "status": final_thread_status,
1346
1340
  "error": json_loads(json_dumpb(exception)) if exception else None,
1347
1341
  }
1342
+ # Without a checkpoint (a run that ended before writing one, or a
1343
+ # rollback) there is nothing to replace values and interrupts with, so
1344
+ # keep the ones the thread already has.
1348
1345
  if checkpoint is not None:
1349
1346
  update["values"] = checkpoint["values"]
1347
+ update["interrupts"] = {
1348
+ t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
1349
+ for t in checkpoint["tasks"]
1350
+ if t.get("interrupts")
1351
+ }
1350
1352
  thread.update(update)
1351
1353
 
1352
1354
  @staticmethod
@@ -2535,6 +2537,7 @@ class Runs(Authenticated):
2535
2537
  ) -> AsyncIterator[Run]:
2536
2538
  """Create a run."""
2537
2539
  from langgraph_api.schema import Run, Thread # noqa: PLC0415
2540
+ from langgraph_api.utils import uuid7 # noqa: PLC0415
2538
2541
 
2539
2542
  assistant_id = _ensure_uuid(assistant_id)
2540
2543
  assistant = next(
@@ -2618,7 +2621,7 @@ class Runs(Authenticated):
2618
2621
  if not existing_thread and (thread_id is None or if_not_exists == "create"):
2619
2622
  # Create new thread
2620
2623
  if thread_id is None:
2621
- thread_id = uuid4()
2624
+ thread_id = uuid7()
2622
2625
 
2623
2626
  thread = Thread(
2624
2627
  thread_id=thread_id,
@@ -2857,6 +2860,7 @@ class Runs(Authenticated):
2857
2860
  status: Literal["pending", "running", "all"] | None = None,
2858
2861
  assistant_id: UUID | None = None,
2859
2862
  ctx: Auth.types.BaseAuthContext | None = None,
2863
+ reason: str | None = None,
2860
2864
  ) -> None:
2861
2865
  """
2862
2866
  Cancel runs in memory. Must provide either:
@@ -2970,6 +2974,7 @@ class Runs(Authenticated):
2970
2974
  control_message = Message(
2971
2975
  topic=f"run:{run_id}:control".encode(),
2972
2976
  data=action.encode(),
2977
+ reason=reason,
2973
2978
  )
2974
2979
  coros.append(stream_manager.put(run_id, thread_id, control_message))
2975
2980
 
@@ -3186,7 +3191,8 @@ class Runs(Authenticated):
3186
3191
  # We only return a stream ID if the run is resumable
3187
3192
  stream_id = (
3188
3193
  id
3189
- if run.get("kwargs", {}).get("resumable")
3194
+ if run is not None
3195
+ and run.get("kwargs", {}).get("resumable")
3190
3196
  else None
3191
3197
  )
3192
3198
  yield mode.encode(), payload, stream_id
@@ -3223,7 +3229,13 @@ class Runs(Authenticated):
3223
3229
  raise e.http_exception from None
3224
3230
  except:
3225
3231
  if cancel_on_disconnect:
3226
- create_task(cancel_run(thread_id, run_id))
3232
+ create_task(
3233
+ cancel_run(
3234
+ thread_id,
3235
+ run_id,
3236
+ reason="client disconnected from the run stream (on_disconnect=cancel)",
3237
+ )
3238
+ )
3227
3239
  raise
3228
3240
  finally:
3229
3241
  stream_manager = get_stream_manager()
@@ -3278,25 +3290,27 @@ async def listen_for_cancellation(
3278
3290
 
3279
3291
  stream_manager = get_stream_manager()
3280
3292
 
3293
+ def _handle_control_message(message: Message) -> bool:
3294
+ """Set `done` from a control message. Returns True once the run is done."""
3295
+ reason = message.reason
3296
+ if message.data == b"rollback":
3297
+ done.set(UserRollback(reason) if reason else UserRollback())
3298
+ elif message.data == b"interrupt":
3299
+ done.set(UserInterrupt(reason) if reason else UserInterrupt())
3300
+ elif message.data == b"done":
3301
+ done.set()
3302
+ return True
3303
+ return False
3304
+
3281
3305
  if control_key := stream_manager.get_control_key(run_id, thread_id):
3282
- payload = control_key.data
3283
- if payload == b"rollback":
3284
- done.set(UserRollback())
3285
- elif payload == b"interrupt":
3286
- done.set(UserInterrupt())
3306
+ _handle_control_message(control_key)
3287
3307
 
3288
3308
  while not done.is_set():
3289
3309
  try:
3290
3310
  # This task gets cancelled when Runs.enter exits anyway,
3291
3311
  # so we can have a pretty lengthy timeout here
3292
3312
  message = await asyncio.wait_for(queue.get(), timeout=240)
3293
- payload = message.data
3294
- if payload == b"rollback":
3295
- done.set(UserRollback())
3296
- elif payload == b"interrupt":
3297
- done.set(UserInterrupt())
3298
- elif payload == b"done":
3299
- done.set()
3313
+ if _handle_control_message(message):
3300
3314
  break
3301
3315
  except TimeoutError:
3302
3316
  break
@@ -3683,7 +3697,8 @@ class Crons(Authenticated):
3683
3697
  continue
3684
3698
  if cron["next_run_date"] > now:
3685
3699
  continue
3686
- yield {**cron, "now": now}
3700
+ # The scheduler mutates the payload to build each run; handing out the stored dict breaks every later fire.
3701
+ yield {**cron, "payload": copy.deepcopy(cron["payload"]), "now": now}
3687
3702
 
3688
3703
  @staticmethod
3689
3704
  async def set_next_run_date(
@@ -3895,10 +3910,13 @@ class Crons(Authenticated):
3895
3910
 
3896
3911
 
3897
3912
  async def cancel_run(
3898
- thread_id: UUID, run_id: UUID, ctx: Auth.types.BaseAuthContext | None = None
3913
+ thread_id: UUID,
3914
+ run_id: UUID,
3915
+ ctx: Auth.types.BaseAuthContext | None = None,
3916
+ reason: str | None = None,
3899
3917
  ) -> None:
3900
3918
  async with connect() as conn:
3901
- await Runs.cancel(conn, [run_id], thread_id=thread_id, ctx=ctx)
3919
+ await Runs.cancel(conn, [run_id], thread_id=thread_id, ctx=ctx, reason=reason)
3902
3920
 
3903
3921
 
3904
3922
  async def _get_checkpointer(