langgraph-runtime-inmem 0.37.0.dev4__tar.gz → 0.37.0rc1__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 (19) hide show
  1. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/PKG-INFO +1 -1
  2. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/__init__.py +1 -1
  3. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/database.py +8 -21
  4. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/ops.py +117 -39
  5. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/.gitignore +0 -0
  6. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/Makefile +0 -0
  7. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/README.md +0 -0
  8. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/_persistence.py +0 -0
  9. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/checkpoint.py +0 -0
  10. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/encryption.py +0 -0
  11. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/inmem_stream.py +0 -0
  12. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/lifespan.py +0 -0
  13. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/metrics.py +0 -0
  14. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/queue.py +0 -0
  15. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/retry.py +0 -0
  16. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/routes.py +0 -0
  17. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/langgraph_runtime_inmem/store.py +0 -0
  18. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/pyproject.toml +0 -0
  19. {langgraph_runtime_inmem-0.37.0.dev4 → langgraph_runtime_inmem-0.37.0rc1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-runtime-inmem
3
- Version: 0.37.0.dev4
3
+ Version: 0.37.0rc1
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
@@ -11,7 +11,7 @@ from langgraph_runtime_inmem import (
11
11
  store,
12
12
  )
13
13
 
14
- __version__ = "0.37.0.dev4"
14
+ __version__ = "0.37.0rc1"
15
15
  __all__ = [
16
16
  "ops",
17
17
  "database",
@@ -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
- """Bring threads from ops files saved by older versions in line with how
199
- status is derived now, as migration 000066 does for postgres.
200
-
201
- Interrupted threads are recorded as having next steps; other threads count as
202
- having none until their next snapshot. Threads whose status was `error` are
203
- recorded as having failed. Cancels, which older versions stored in the thread's
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
- has_next = store.setdefault("thread_has_next", {})
208
- failed = store.setdefault("thread_failed", {})
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; an unknown fact counts as no.
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
- if any(
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
- return "busy"
146
- if failed is None:
147
- failed = _thread_failed(conn).get(thread_id, False)
148
- if failed:
149
- return "error"
150
- if has_next is None:
151
- has_next = _thread_has_next(conn).get(thread_id, False)
152
- if has_next:
153
- return "interrupted"
154
- return "idle"
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(
@@ -209,7 +239,8 @@ async def _save_thread_status(
209
239
 
210
240
  def _run_stream_mode_matches(event_mode: str, stream_mode: list[str] | None) -> bool:
211
241
  """Return True if a published run-stream event matches the join filter."""
212
- if not stream_mode:
242
+ # A run's error is its outcome, not a selectable mode, so no filter drops it.
243
+ if not stream_mode or event_mode == "error":
213
244
  return True
214
245
  if event_mode in stream_mode:
215
246
  return True
@@ -947,15 +978,24 @@ def is_jsonb_contained(superset: dict[str, Any], subset: dict[str, Any]) -> bool
947
978
  """
948
979
  if not isinstance(superset, dict):
949
980
  return False
950
- for key, value in subset.items():
951
- if key not in superset:
952
- return False
953
- if isinstance(value, dict) and isinstance(superset[key], dict):
954
- if not is_jsonb_contained(superset[key], value):
955
- return False
956
- elif superset[key] != value:
957
- return False
958
- return True
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
959
999
 
960
1000
 
961
1001
  def bytes_decoder(obj):
@@ -1059,6 +1099,7 @@ class Threads(Authenticated):
1059
1099
  sort_order: str | None = None,
1060
1100
  select: list[ThreadSelectField] | None = None,
1061
1101
  extract: dict[str, str] | None = None,
1102
+ exclude_metadata: list[dict[str, Any]] | None = None,
1062
1103
  ctx: Auth.types.BaseAuthContext | None = None,
1063
1104
  ) -> tuple[AsyncIterator[Thread], int]:
1064
1105
  threads = conn.store["threads"]
@@ -1101,6 +1142,12 @@ class Threads(Authenticated):
1101
1142
  if metadata and not is_jsonb_contained(thread["metadata"], metadata):
1102
1143
  continue
1103
1144
 
1145
+ if exclude_metadata and any(
1146
+ is_jsonb_contained(thread["metadata"], excluded)
1147
+ for excluded in exclude_metadata
1148
+ ):
1149
+ continue
1150
+
1104
1151
  if (
1105
1152
  values
1106
1153
  and "values" in thread
@@ -1682,19 +1729,30 @@ class Threads(Authenticated):
1682
1729
  # The copy gets the original's checkpoints below, so it starts where the
1683
1730
  # original stands: same values, interrupts and error, and the same fact
1684
1731
  # about whether its graph has next steps. Its status comes from those,
1685
- # 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).
1686
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
+ )
1687
1745
  new_thread: Thread = {
1688
1746
  "thread_id": new_thread_id,
1689
1747
  "created_at": now,
1690
1748
  "updated_at": now,
1691
1749
  "state_updated_at": now,
1692
1750
  "metadata": copy.deepcopy(original_thread["metadata"]),
1693
- "status": _thread_status(
1694
- conn,
1695
- new_thread_id,
1696
- failed=_thread_failed(conn).get(thread_id, False),
1697
- has_next=_thread_has_next(conn).get(thread_id, False),
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"],
1698
1756
  ),
1699
1757
  "config": {},
1700
1758
  "values": copy.deepcopy(original_thread.get("values")),
@@ -2461,6 +2519,7 @@ class Threads(Authenticated):
2461
2519
  metadata: MetadataInput = None,
2462
2520
  values: MetadataInput = None,
2463
2521
  status: ThreadStatus | None = None,
2522
+ exclude_metadata: list[dict[str, Any]] | None = None,
2464
2523
  ctx: Auth.types.BaseAuthContext | None = None,
2465
2524
  ) -> int:
2466
2525
  """Get count of threads."""
@@ -2489,6 +2548,12 @@ class Threads(Authenticated):
2489
2548
  if metadata and not is_jsonb_contained(thread["metadata"], metadata):
2490
2549
  continue
2491
2550
 
2551
+ if exclude_metadata and any(
2552
+ is_jsonb_contained(thread["metadata"], excluded)
2553
+ for excluded in exclude_metadata
2554
+ ):
2555
+ continue
2556
+
2492
2557
  if (
2493
2558
  values
2494
2559
  and "values" in thread
@@ -2812,6 +2877,8 @@ class Runs(Authenticated):
2812
2877
  if not _check_filter_match(existing_thread["metadata"], filters):
2813
2878
  return _empty_generator()
2814
2879
 
2880
+ _reject_existing_run(conn.store["runs"], run_id)
2881
+
2815
2882
  if not existing_thread and (thread_id is None or if_not_exists == "create"):
2816
2883
  # Create new thread
2817
2884
  if thread_id is None:
@@ -2950,6 +3017,7 @@ class Runs(Authenticated):
2950
3017
  cancel_reason=None,
2951
3018
  )
2952
3019
  stored_run = new_run
3020
+ encrypted_thread = None
2953
3021
  if encryption_enabled:
2954
3022
  # Unlike Go core, in-memory workers read nested run kwargs and thread
2955
3023
  # config directly from the store, so those containers must stay readable.
@@ -2957,14 +3025,16 @@ class Runs(Authenticated):
2957
3025
  thread if not existing_thread else existing_thread,
2958
3026
  "thread",
2959
3027
  )
3028
+ stored_run = copy.deepcopy(new_run)
3029
+ stored_run = Run(**await _encrypt_for_storage(stored_run, "run"))
3030
+ _reject_existing_run(conn.store["runs"], run_id)
3031
+ if encrypted_thread is not None:
2960
3032
  stored_thread = next(
2961
3033
  t
2962
3034
  for t in conn.store["threads"]
2963
3035
  if t["thread_id"] == encrypted_thread["thread_id"]
2964
3036
  )
2965
3037
  stored_thread.update(encrypted_thread)
2966
- stored_run = copy.deepcopy(new_run)
2967
- stored_run = Run(**await _encrypt_for_storage(stored_run, "run"))
2968
3038
  conn.store["runs"].append(stored_run)
2969
3039
 
2970
3040
  async def _yield_new():
@@ -3178,15 +3248,19 @@ class Runs(Authenticated):
3178
3248
  data=action.encode(),
3179
3249
  reason=reason,
3180
3250
  )
3181
- coros.append(stream_manager.put(run_id, thread_id, control_message))
3251
+ # Cancels by status or assistant have no thread_id; the worker listens on the run's own.
3252
+ coros.append(stream_manager.put(run_id, run["thread_id"], control_message))
3182
3253
 
3183
- queues = stream_manager.get_queues(run_id, thread_id)
3254
+ queues = stream_manager.get_queues(run_id, run["thread_id"])
3184
3255
 
3185
3256
  if run["status"] in ("pending", "running"):
3186
3257
  cancelable_runs.append(run)
3187
3258
  if run["status"] == "pending":
3188
3259
  pending_thread_ids.add(run["thread_id"])
3189
- if queues or action != "rollback":
3260
+ if action == "rollback" and run["status"] == "running":
3261
+ # Its worker finalizes the rollback; staying "running" holds the thread's next run until then.
3262
+ pass
3263
+ elif queues or action != "rollback":
3190
3264
  run["status"] = "interrupted"
3191
3265
  run["cancel_reason"] = reason
3192
3266
  run["updated_at"] = datetime.now(tz=UTC)
@@ -3194,7 +3268,6 @@ class Runs(Authenticated):
3194
3268
  await logger.ainfo(
3195
3269
  "Eagerly deleting pending run with rollback action",
3196
3270
  run_id=str(run_id),
3197
- status=run["status"],
3198
3271
  )
3199
3272
  coros.append(Runs.delete(conn, run_id, thread_id=run["thread_id"]))
3200
3273
  else:
@@ -4429,6 +4502,11 @@ def _check_filter_match(
4429
4502
  return True
4430
4503
 
4431
4504
 
4505
+ def _reject_existing_run(runs: Sequence[Run], run_id: UUID | None) -> None:
4506
+ if any(run["run_id"] == run_id for run in runs):
4507
+ raise HTTPException(status_code=409, detail=f"Run {run_id} already exists")
4508
+
4509
+
4432
4510
  async def _empty_generator():
4433
4511
  if False:
4434
4512
  yield