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.
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/Makefile +1 -2
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/PKG-INFO +1 -1
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/__init__.py +1 -1
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/checkpoint.py +47 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/database.py +24 -3
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/inmem_stream.py +2 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/ops.py +61 -43
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/.gitignore +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/README.md +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/_persistence.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/lifespan.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/metrics.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/queue.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/retry.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/routes.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/store.py +0 -0
- {langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/pyproject.toml +0 -0
- {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 *"
|
|
@@ -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
|
-
|
|
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
|
{langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/ops.py
RENAMED
|
@@ -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
|
-
|
|
860
|
-
|
|
861
|
-
"
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/metrics.py
RENAMED
|
File without changes
|
{langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/queue.py
RENAMED
|
File without changes
|
{langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/retry.py
RENAMED
|
File without changes
|
{langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/routes.py
RENAMED
|
File without changes
|
{langgraph_runtime_inmem-0.35.4 → langgraph_runtime_inmem-0.36.0}/langgraph_runtime_inmem/store.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|