langgraph-runtime-inmem 0.37.0.dev5__tar.gz → 0.37.0rc2__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.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/PKG-INFO +1 -1
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/__init__.py +1 -1
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/database.py +8 -21
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/ops.py +123 -37
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/.gitignore +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/Makefile +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/README.md +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/_persistence.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/checkpoint.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/encryption.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/inmem_stream.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/lifespan.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/metrics.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/queue.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/retry.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/routes.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/langgraph_runtime_inmem/store.py +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/pyproject.toml +0 -0
- {langgraph_runtime_inmem-0.37.0.dev5 → langgraph_runtime_inmem-0.37.0rc2}/uv.lock +0 -0
|
@@ -195,28 +195,15 @@ def _load_ops_file() -> None:
|
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
def upgrade_thread_status_facts(store: dict[str, Any]) -> None:
|
|
198
|
-
"""
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
error, are cleared when the error reads as exactly one; anything else, encrypted
|
|
205
|
-
errors included, stays.
|
|
198
|
+
"""Ensure fact maps exist for ops files saved by older versions.
|
|
199
|
+
|
|
200
|
+
Migration 000066 only adds nullable has_next / failed columns; it does not
|
|
201
|
+
backfill. Writers fill the facts on the next status write, and status
|
|
202
|
+
re-derivation keeps the stored status while either fact is still unknown.
|
|
203
|
+
Cancels that older versions left in ``error`` are left as-is.
|
|
206
204
|
"""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for thread in store["threads"]:
|
|
210
|
-
if thread.get("status") == "interrupted":
|
|
211
|
-
has_next.setdefault(thread["thread_id"], True)
|
|
212
|
-
failed.setdefault(thread["thread_id"], thread.get("status") == "error")
|
|
213
|
-
error = thread.get("error")
|
|
214
|
-
if (
|
|
215
|
-
isinstance(error, dict)
|
|
216
|
-
and "__encryption_context__" not in error
|
|
217
|
-
and error.get("error") in ("UserInterrupt", "UserRollback")
|
|
218
|
-
):
|
|
219
|
-
thread["error"] = None
|
|
205
|
+
store.setdefault("thread_has_next", {})
|
|
206
|
+
store.setdefault("thread_failed", {})
|
|
220
207
|
|
|
221
208
|
|
|
222
209
|
def _set_aside_ops_file(reason: Exception) -> None:
|
|
@@ -122,6 +122,34 @@ def _thread_failure(exception: BaseException | None) -> BaseException | None:
|
|
|
122
122
|
return None if isinstance(exception, UserInterrupt) else exception
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def _derive_thread_status(
|
|
126
|
+
*,
|
|
127
|
+
has_active_runs: bool,
|
|
128
|
+
failed: bool | None,
|
|
129
|
+
has_next: bool | None,
|
|
130
|
+
existing_status: ThreadStatus,
|
|
131
|
+
) -> ThreadStatus:
|
|
132
|
+
"""The one rule for a thread's status from known facts.
|
|
133
|
+
|
|
134
|
+
busy if a run is active; otherwise error if the last run failed; otherwise
|
|
135
|
+
interrupted if the graph has next steps; otherwise keep ``existing_status``
|
|
136
|
+
when ``failed`` or ``has_next`` is still unknown (None); otherwise idle.
|
|
137
|
+
|
|
138
|
+
A leftover busy with no active runs becomes idle.
|
|
139
|
+
"""
|
|
140
|
+
if has_active_runs:
|
|
141
|
+
return "busy"
|
|
142
|
+
if failed:
|
|
143
|
+
return "error"
|
|
144
|
+
if has_next:
|
|
145
|
+
return "interrupted"
|
|
146
|
+
if failed is None or has_next is None:
|
|
147
|
+
if existing_status == "busy":
|
|
148
|
+
return "idle"
|
|
149
|
+
return existing_status
|
|
150
|
+
return "idle"
|
|
151
|
+
|
|
152
|
+
|
|
125
153
|
def _thread_status(
|
|
126
154
|
conn: InMemConnectionProto,
|
|
127
155
|
thread_id: UUID,
|
|
@@ -131,27 +159,29 @@ def _thread_status(
|
|
|
131
159
|
) -> ThreadStatus:
|
|
132
160
|
"""The one rule for a thread's status, used by every write that changes it.
|
|
133
161
|
|
|
134
|
-
busy if a run on the thread is pending or running; otherwise error if the last
|
|
135
|
-
run failed; otherwise interrupted if the graph has next steps (an interrupt or a
|
|
136
|
-
breakpoint); otherwise idle.
|
|
137
|
-
|
|
138
162
|
A nil ``failed`` or ``has_next`` means the caller doesn't know the fact, so it is
|
|
139
|
-
read from what is stored on the thread;
|
|
163
|
+
read from what is stored on the thread; a fact still missing from the store is
|
|
164
|
+
unknown and keeps the thread's current status (see ``_derive_thread_status``).
|
|
140
165
|
"""
|
|
141
|
-
|
|
166
|
+
has_active_runs = any(
|
|
142
167
|
r["thread_id"] == thread_id and r["status"] in ("pending", "running")
|
|
143
168
|
for r in conn.store["runs"]
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
169
|
+
)
|
|
170
|
+
if failed is None and thread_id in _thread_failed(conn):
|
|
171
|
+
failed = _thread_failed(conn)[thread_id]
|
|
172
|
+
if has_next is None and thread_id in _thread_has_next(conn):
|
|
173
|
+
has_next = _thread_has_next(conn)[thread_id]
|
|
174
|
+
existing_status: ThreadStatus = "idle"
|
|
175
|
+
for thread in conn.store["threads"]:
|
|
176
|
+
if thread["thread_id"] == thread_id:
|
|
177
|
+
existing_status = thread["status"]
|
|
178
|
+
break
|
|
179
|
+
return _derive_thread_status(
|
|
180
|
+
has_active_runs=has_active_runs,
|
|
181
|
+
failed=failed,
|
|
182
|
+
has_next=has_next,
|
|
183
|
+
existing_status=existing_status,
|
|
184
|
+
)
|
|
155
185
|
|
|
156
186
|
|
|
157
187
|
async def _save_thread_status(
|
|
@@ -948,15 +978,24 @@ def is_jsonb_contained(superset: dict[str, Any], subset: dict[str, Any]) -> bool
|
|
|
948
978
|
"""
|
|
949
979
|
if not isinstance(superset, dict):
|
|
950
980
|
return False
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
981
|
+
return all(
|
|
982
|
+
key in superset and _is_jsonb_value_contained(superset[key], value)
|
|
983
|
+
for key, value in subset.items()
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _is_jsonb_value_contained(superset: Any, subset: Any) -> bool:
|
|
988
|
+
"""Implements Postgres' @> semantics for arbitrary JSON values."""
|
|
989
|
+
if isinstance(subset, dict):
|
|
990
|
+
return isinstance(superset, dict) and is_jsonb_contained(superset, subset)
|
|
991
|
+
if isinstance(subset, list):
|
|
992
|
+
return isinstance(superset, list) and all(
|
|
993
|
+
any(_is_jsonb_value_contained(item, sub_item) for item in superset)
|
|
994
|
+
for sub_item in subset
|
|
995
|
+
)
|
|
996
|
+
if isinstance(superset, bool) or isinstance(subset, bool):
|
|
997
|
+
return superset is subset
|
|
998
|
+
return superset == subset
|
|
960
999
|
|
|
961
1000
|
|
|
962
1001
|
def bytes_decoder(obj):
|
|
@@ -1060,6 +1099,7 @@ class Threads(Authenticated):
|
|
|
1060
1099
|
sort_order: str | None = None,
|
|
1061
1100
|
select: list[ThreadSelectField] | None = None,
|
|
1062
1101
|
extract: dict[str, str] | None = None,
|
|
1102
|
+
exclude_metadata: list[dict[str, Any]] | None = None,
|
|
1063
1103
|
ctx: Auth.types.BaseAuthContext | None = None,
|
|
1064
1104
|
) -> tuple[AsyncIterator[Thread], int]:
|
|
1065
1105
|
threads = conn.store["threads"]
|
|
@@ -1102,6 +1142,12 @@ class Threads(Authenticated):
|
|
|
1102
1142
|
if metadata and not is_jsonb_contained(thread["metadata"], metadata):
|
|
1103
1143
|
continue
|
|
1104
1144
|
|
|
1145
|
+
if exclude_metadata and any(
|
|
1146
|
+
is_jsonb_contained(thread["metadata"], excluded)
|
|
1147
|
+
for excluded in exclude_metadata
|
|
1148
|
+
):
|
|
1149
|
+
continue
|
|
1150
|
+
|
|
1105
1151
|
if (
|
|
1106
1152
|
values
|
|
1107
1153
|
and "values" in thread
|
|
@@ -1683,19 +1729,30 @@ class Threads(Authenticated):
|
|
|
1683
1729
|
# The copy gets the original's checkpoints below, so it starts where the
|
|
1684
1730
|
# original stands: same values, interrupts and error, and the same fact
|
|
1685
1731
|
# about whether its graph has next steps. Its status comes from those,
|
|
1686
|
-
# by the usual rule: the original's runs are not the copy's.
|
|
1732
|
+
# by the usual rule: the original's runs are not the copy's. Missing
|
|
1733
|
+
# facts keep the original's status (except a stale busy → idle).
|
|
1687
1734
|
now = datetime.now(tz=UTC)
|
|
1735
|
+
src_failed = (
|
|
1736
|
+
_thread_failed(conn)[thread_id]
|
|
1737
|
+
if thread_id in _thread_failed(conn)
|
|
1738
|
+
else None
|
|
1739
|
+
)
|
|
1740
|
+
src_has_next = (
|
|
1741
|
+
_thread_has_next(conn)[thread_id]
|
|
1742
|
+
if thread_id in _thread_has_next(conn)
|
|
1743
|
+
else None
|
|
1744
|
+
)
|
|
1688
1745
|
new_thread: Thread = {
|
|
1689
1746
|
"thread_id": new_thread_id,
|
|
1690
1747
|
"created_at": now,
|
|
1691
1748
|
"updated_at": now,
|
|
1692
1749
|
"state_updated_at": now,
|
|
1693
1750
|
"metadata": copy.deepcopy(original_thread["metadata"]),
|
|
1694
|
-
"status":
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1751
|
+
"status": _derive_thread_status(
|
|
1752
|
+
has_active_runs=False,
|
|
1753
|
+
failed=src_failed,
|
|
1754
|
+
has_next=src_has_next,
|
|
1755
|
+
existing_status=original_thread["status"],
|
|
1699
1756
|
),
|
|
1700
1757
|
"config": {},
|
|
1701
1758
|
"values": copy.deepcopy(original_thread.get("values")),
|
|
@@ -2239,9 +2296,16 @@ class Threads(Authenticated):
|
|
|
2239
2296
|
last_event_id: str | None = None,
|
|
2240
2297
|
stream_modes: list[ThreadStreamMode],
|
|
2241
2298
|
ctx: Auth.types.BaseAuthContext | None = None,
|
|
2299
|
+
auth_filters: Auth.types.FilterType | None = None,
|
|
2242
2300
|
) -> AsyncIterator[tuple[bytes, bytes, bytes | None, str | None]]:
|
|
2243
|
-
"""Stream the thread output.
|
|
2244
|
-
|
|
2301
|
+
"""Stream the thread output.
|
|
2302
|
+
|
|
2303
|
+
``auth_filters`` comes from a ``check_thread_stream_auth`` the
|
|
2304
|
+
caller already ran, so the read handler doesn't run twice. With
|
|
2305
|
+
``None``, the check runs here.
|
|
2306
|
+
"""
|
|
2307
|
+
if auth_filters is None:
|
|
2308
|
+
await Threads.Stream.check_thread_stream_auth(thread_id, ctx)
|
|
2245
2309
|
|
|
2246
2310
|
from langgraph_api.utils.stream_codec import ( # noqa: PLC0415
|
|
2247
2311
|
decode_stream_message,
|
|
@@ -2441,7 +2505,11 @@ class Threads(Authenticated):
|
|
|
2441
2505
|
async def check_thread_stream_auth(
|
|
2442
2506
|
thread_id: UUID,
|
|
2443
2507
|
ctx: Auth.types.BaseAuthContext | None = None,
|
|
2444
|
-
) ->
|
|
2508
|
+
) -> Auth.types.FilterType:
|
|
2509
|
+
"""Raise 403/404 for a thread the caller can't read.
|
|
2510
|
+
|
|
2511
|
+
Returns the auth filters for ``join_event_streaming``.
|
|
2512
|
+
"""
|
|
2445
2513
|
async with connect() as conn:
|
|
2446
2514
|
filters = await Threads.Stream.handle_event(
|
|
2447
2515
|
ctx,
|
|
@@ -2454,6 +2522,7 @@ class Threads(Authenticated):
|
|
|
2454
2522
|
)
|
|
2455
2523
|
if not thread:
|
|
2456
2524
|
raise HTTPException(status_code=404, detail="Thread not found")
|
|
2525
|
+
return filters or {}
|
|
2457
2526
|
|
|
2458
2527
|
@staticmethod
|
|
2459
2528
|
async def count(
|
|
@@ -2462,6 +2531,7 @@ class Threads(Authenticated):
|
|
|
2462
2531
|
metadata: MetadataInput = None,
|
|
2463
2532
|
values: MetadataInput = None,
|
|
2464
2533
|
status: ThreadStatus | None = None,
|
|
2534
|
+
exclude_metadata: list[dict[str, Any]] | None = None,
|
|
2465
2535
|
ctx: Auth.types.BaseAuthContext | None = None,
|
|
2466
2536
|
) -> int:
|
|
2467
2537
|
"""Get count of threads."""
|
|
@@ -2490,6 +2560,12 @@ class Threads(Authenticated):
|
|
|
2490
2560
|
if metadata and not is_jsonb_contained(thread["metadata"], metadata):
|
|
2491
2561
|
continue
|
|
2492
2562
|
|
|
2563
|
+
if exclude_metadata and any(
|
|
2564
|
+
is_jsonb_contained(thread["metadata"], excluded)
|
|
2565
|
+
for excluded in exclude_metadata
|
|
2566
|
+
):
|
|
2567
|
+
continue
|
|
2568
|
+
|
|
2493
2569
|
if (
|
|
2494
2570
|
values
|
|
2495
2571
|
and "values" in thread
|
|
@@ -2813,6 +2889,8 @@ class Runs(Authenticated):
|
|
|
2813
2889
|
if not _check_filter_match(existing_thread["metadata"], filters):
|
|
2814
2890
|
return _empty_generator()
|
|
2815
2891
|
|
|
2892
|
+
_reject_existing_run(conn.store["runs"], run_id)
|
|
2893
|
+
|
|
2816
2894
|
if not existing_thread and (thread_id is None or if_not_exists == "create"):
|
|
2817
2895
|
# Create new thread
|
|
2818
2896
|
if thread_id is None:
|
|
@@ -2951,6 +3029,7 @@ class Runs(Authenticated):
|
|
|
2951
3029
|
cancel_reason=None,
|
|
2952
3030
|
)
|
|
2953
3031
|
stored_run = new_run
|
|
3032
|
+
encrypted_thread = None
|
|
2954
3033
|
if encryption_enabled:
|
|
2955
3034
|
# Unlike Go core, in-memory workers read nested run kwargs and thread
|
|
2956
3035
|
# config directly from the store, so those containers must stay readable.
|
|
@@ -2958,14 +3037,16 @@ class Runs(Authenticated):
|
|
|
2958
3037
|
thread if not existing_thread else existing_thread,
|
|
2959
3038
|
"thread",
|
|
2960
3039
|
)
|
|
3040
|
+
stored_run = copy.deepcopy(new_run)
|
|
3041
|
+
stored_run = Run(**await _encrypt_for_storage(stored_run, "run"))
|
|
3042
|
+
_reject_existing_run(conn.store["runs"], run_id)
|
|
3043
|
+
if encrypted_thread is not None:
|
|
2961
3044
|
stored_thread = next(
|
|
2962
3045
|
t
|
|
2963
3046
|
for t in conn.store["threads"]
|
|
2964
3047
|
if t["thread_id"] == encrypted_thread["thread_id"]
|
|
2965
3048
|
)
|
|
2966
3049
|
stored_thread.update(encrypted_thread)
|
|
2967
|
-
stored_run = copy.deepcopy(new_run)
|
|
2968
|
-
stored_run = Run(**await _encrypt_for_storage(stored_run, "run"))
|
|
2969
3050
|
conn.store["runs"].append(stored_run)
|
|
2970
3051
|
|
|
2971
3052
|
async def _yield_new():
|
|
@@ -4433,6 +4514,11 @@ def _check_filter_match(
|
|
|
4433
4514
|
return True
|
|
4434
4515
|
|
|
4435
4516
|
|
|
4517
|
+
def _reject_existing_run(runs: Sequence[Run], run_id: UUID | None) -> None:
|
|
4518
|
+
if any(run["run_id"] == run_id for run in runs):
|
|
4519
|
+
raise HTTPException(status_code=409, detail=f"Run {run_id} already exists")
|
|
4520
|
+
|
|
4521
|
+
|
|
4436
4522
|
async def _empty_generator():
|
|
4437
4523
|
if False:
|
|
4438
4524
|
yield
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|