harness-sdk-python 0.22.0__tar.gz → 0.23.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 (36) hide show
  1. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/src/harness_sdk/__init__.py +2 -0
  4. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/src/harness_sdk/checkpoints.py +64 -0
  5. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_checkpoints.py +91 -0
  6. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/.gitignore +0 -0
  7. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/README.md +0 -0
  8. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/examples/__init__.py +0 -0
  9. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/examples/runs_app.py +0 -0
  10. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/src/harness_sdk/fenced_postgres.py +0 -0
  11. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/src/harness_sdk/linear_thread.py +0 -0
  12. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/src/harness_sdk/run_manager.py +0 -0
  13. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/run_helpers.py +0 -0
  14. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_admit_steer.py +0 -0
  15. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_applied_visibility.py +0 -0
  16. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_batches.py +0 -0
  17. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_branch_anchor.py +0 -0
  18. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_dispatching.py +0 -0
  19. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_durability.py +0 -0
  20. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_edit_dispatched.py +0 -0
  21. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_edit_reload.py +0 -0
  22. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_enqueue.py +0 -0
  23. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_fenced_postgres.py +0 -0
  24. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_input_required.py +0 -0
  25. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_linear_thread.py +0 -0
  26. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_meta.py +0 -0
  27. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_outcomes.py +0 -0
  28. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_placement.py +0 -0
  29. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_prepare_hooks.py +0 -0
  30. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_restore.py +0 -0
  31. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_rewind_during_run.py +0 -0
  32. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_run_crash_sweep.py +0 -0
  33. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_settle.py +0 -0
  34. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_steer.py +0 -0
  35. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_stop_continue.py +0 -0
  36. {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.0}/tests/test_stopping_window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.22.0"
3
+ version = "0.23.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,6 +8,7 @@ from .checkpoints import (
8
8
  Snapshot,
9
9
  checkpoint_thread,
10
10
  langgraph_checkpoints,
11
+ langgraph_switch_values,
11
12
  )
12
13
  from .linear_thread import linear_thread
13
14
  from .run_manager import RunManager, RunManagerDescriptor, run_manager
@@ -24,6 +25,7 @@ __all__ = [
24
25
  "Snapshot",
25
26
  "checkpoint_thread",
26
27
  "langgraph_checkpoints",
28
+ "langgraph_switch_values",
27
29
  "linear_thread",
28
30
  "run_manager",
29
31
  ]
@@ -312,6 +312,26 @@ def langgraph_checkpoints(
312
312
  return _Store()
313
313
 
314
314
 
315
+ def langgraph_switch_values(
316
+ transcript: list[dict[str, Any]],
317
+ lineage: dict[str, Any],
318
+ *,
319
+ messages_key: str = "messages",
320
+ lineage_key: str = "lineage",
321
+ ) -> dict[str, Any]:
322
+ """``update_state`` channel values that replace the messages channel with ``transcript`` and set ``lineage``."""
323
+ from langchain_core.messages import RemoveMessage, convert_to_messages
324
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES
325
+
326
+ return {
327
+ messages_key: [
328
+ RemoveMessage(id=REMOVE_ALL_MESSAGES),
329
+ *convert_to_messages([_message_dict(m) for m in transcript]),
330
+ ],
331
+ lineage_key: lineage,
332
+ }
333
+
334
+
315
335
  @dataclass
316
336
  class CheckpointThread:
317
337
  """One thread's current window over the store, with the sidecar index kept in step."""
@@ -351,6 +371,25 @@ class CheckpointThread:
351
371
  if entry["fork_message_id"] == parent_id
352
372
  ]
353
373
 
374
+ def _branch_at(self, index: int) -> int:
375
+ branch = 0
376
+ for b in self._path:
377
+ fork_id = self.branches[b - 1]["fork_message_id"] if b != 0 else None
378
+ start = next(
379
+ (i + 1 for i, m in enumerate(self.transcript) if m["id"] == fork_id), 0
380
+ )
381
+ if start <= index:
382
+ branch = b
383
+ return branch
384
+
385
+ def sibling_info(self, message_id: str) -> tuple[int, int]:
386
+ """1-based (position, count) of ``message_id`` within its sibling fan-out."""
387
+ entries = self.sibling_branches(message_id)
388
+ index = next(i for i, m in enumerate(self.transcript) if m["id"] == message_id)
389
+ branch = self._branch_at(index)
390
+ group = sorted({e["branch_id"] for e in entries} | {branch})
391
+ return group.index(branch) + 1, len(group)
392
+
354
393
  async def plan(self, trigger: str, *, rollback_to: str | None = None) -> Plan:
355
394
  """The resumption for ``trigger``; a fork trigger names its last surviving message via ``rollback_to``."""
356
395
  if trigger in _TIP_TRIGGERS:
@@ -471,6 +510,31 @@ class CheckpointThread:
471
510
  """Load the branch's subtree tip as the current window."""
472
511
  return self._load(await self._get(self._tip_of(branch_id)))
473
512
 
513
+ async def _world_path(self, branch_id: int) -> list[int]:
514
+ path = [0]
515
+ while branch_id != 0:
516
+ path.insert(1, branch_id)
517
+ fork_id = self.branches[branch_id - 1]["fork_message_id"]
518
+ if fork_id is None:
519
+ break
520
+ row = await self._index.get_message(self._thread_id, self._ns, fork_id)
521
+ if row is None:
522
+ raise KeyError(f"fork message {fork_id!r} is not indexed")
523
+ branch_id = row["branch_id"]
524
+ return path
525
+
526
+ async def switch_state(
527
+ self, branch_id: int
528
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
529
+ """Switch like ``switch_branch`` and return (transcript, lineage) for the host to write back into graph state."""
530
+ await self.switch_branch(branch_id)
531
+ lineage = self.lineage or {
532
+ "branch_ids": await self._world_path(branch_id),
533
+ "parent_checkpoint_id": None,
534
+ }
535
+ self._path = list(lineage["branch_ids"])
536
+ return list(self.transcript), lineage
537
+
474
538
  async def page_up(self) -> "CheckpointThread":
475
539
  """Load the parent world's tail checkpoint as the current window."""
476
540
  parent = (self.lineage or {}).get("parent_checkpoint_id")
@@ -13,6 +13,7 @@ from harness_sdk import (
13
13
  Snapshot,
14
14
  checkpoint_thread,
15
15
  langgraph_checkpoints,
16
+ langgraph_switch_values,
16
17
  )
17
18
 
18
19
  THREAD = "t1"
@@ -242,6 +243,29 @@ async def test_sibling_branches_order_by_allocation():
242
243
  thread.sibling_branches("a2")
243
244
 
244
245
 
246
+ async def test_sibling_info_positions_across_a_fan_out():
247
+ store, _, thread = await seeded()
248
+ for cid, reply in [("cp3", "a2b"), ("cp4", "a2c"), ("cp5", "a2d")]:
249
+ await thread.plan("message-reload", rollback_to="u2")
250
+ await thread.ingest(store.put(cid, [U1, A1, U2, msg(reply, "ai")]))
251
+ assert thread.sibling_info("a2d") == (3, 3)
252
+ assert thread.sibling_info("u2") == (1, 1)
253
+ lineage = {"branch_ids": [0, 1], "parent_checkpoint_id": None}
254
+ await thread.ingest(store.put("cp6", [U1, A1, U2, msg("a2b", "ai")], lineage))
255
+ assert thread.sibling_info("a2b") == (1, 3)
256
+
257
+
258
+ async def test_sibling_info_counts_the_window_timeline_among_the_forks():
259
+ store, _, thread = await seeded()
260
+ await thread.plan("message-edit", rollback_to="a1")
261
+ await thread.ingest(store.put("cp3", [U1, A1, msg("u2c"), msg("a2c", "ai")]))
262
+ assert thread.sibling_info("u2c") == (1, 1)
263
+ lineage = {"branch_ids": [0], "parent_checkpoint_id": None}
264
+ await thread.ingest(store.put("cp4", [U1, A1, U2, A2], lineage))
265
+ assert thread.sibling_info("u2") == (1, 2) # branch 0 orders before the edit fork
266
+ assert thread.sibling_info("u1") == (1, 1)
267
+
268
+
245
269
  # ─── Switch branch ──────────────────────────────────────────
246
270
 
247
271
 
@@ -267,6 +291,51 @@ async def test_switch_branch_unknown_raises():
267
291
  await thread.switch_branch(7)
268
292
 
269
293
 
294
+ # ─── Switch as a state write ────────────────────────────────
295
+
296
+
297
+ async def forked():
298
+ """Two reloads at u2: branch 1 tips cp3 with a2b, branch 2 tips cp4 with a2c."""
299
+ store, index, thread = await seeded()
300
+ await thread.plan("message-reload", rollback_to="u2")
301
+ a2b = msg("a2b", "ai")
302
+ await thread.ingest(store.put("cp3", [U1, A1, U2, a2b]))
303
+ await thread.plan("message-reload", rollback_to="u2")
304
+ await thread.ingest(store.put("cp4", [U1, A1, U2, msg("a2c", "ai")]))
305
+ return store, index, thread, [U1, A1, U2, a2b]
306
+
307
+
308
+ async def test_switch_state_returns_the_transcript_and_world_lineage():
309
+ _, _, thread, world = await forked()
310
+ transcript, lineage = await thread.switch_state(1)
311
+ assert transcript == world
312
+ assert lineage == {"branch_ids": [0, 1], "parent_checkpoint_id": None}
313
+ assert thread.checkpoint_id == "cp3"
314
+ assert thread.transcript == world
315
+
316
+
317
+ async def test_switch_state_returns_a_compacted_world_lineage():
318
+ _, _, thread, window = await compacted()
319
+ await thread.page_up()
320
+ transcript, lineage = await thread.switch_state(1)
321
+ assert transcript == window
322
+ assert lineage == {"branch_ids": [1], "parent_checkpoint_id": "cp2"}
323
+
324
+
325
+ async def test_switch_state_ingest_round_trip_keeps_the_index_consistent():
326
+ store, index, thread, _ = await forked()
327
+ transcript, lineage = await thread.switch_state(1)
328
+ rows_before = len(index._messages)
329
+ await thread.ingest(store.put("cp5", transcript, lineage))
330
+ assert len(index._messages) == rows_before
331
+ branches = await index.read_thread(THREAD, "")
332
+ assert branches[0]["last_checkpoint_id"] == "cp5"
333
+ assert branches[1]["last_checkpoint_id"] == "cp4"
334
+ assert await row(index, "a2b") == {"anchor_checkpoint_id": "cp1", "branch_id": 1}
335
+ assert thread.checkpoint_id == "cp5"
336
+ assert thread.sibling_info("a2b") == (1, 2)
337
+
338
+
270
339
  # ─── Paging across a compaction ─────────────────────────────
271
340
 
272
341
 
@@ -405,6 +474,28 @@ async def test_langgraph_adapter_rejects_a_message_without_an_id():
405
474
  await store.get(None)
406
475
 
407
476
 
477
+ async def test_langgraph_switch_values_replaces_the_messages_channel():
478
+ pytest.importorskip("langgraph.graph")
479
+ from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
480
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES
481
+
482
+ lineage = {"branch_ids": [0, 1], "parent_checkpoint_id": None}
483
+ values = langgraph_switch_values(
484
+ [
485
+ {"id": "u1", "type": "human", "content": "hi"},
486
+ {"id": "a1", "type": "ai", "content": "yo"},
487
+ ],
488
+ lineage,
489
+ )
490
+ remove, u1, a1 = values["messages"]
491
+ assert remove == RemoveMessage(id=REMOVE_ALL_MESSAGES)
492
+ assert isinstance(u1, HumanMessage) and u1.id == "u1" and u1.content == "hi"
493
+ assert isinstance(a1, AIMessage) and a1.id == "a1" and a1.content == "yo"
494
+ assert values["lineage"] == lineage
495
+ with pytest.raises(ValueError, match="without an id"):
496
+ langgraph_switch_values([{"type": "human", "content": "x"}], lineage)
497
+
498
+
408
499
  # ─── PostgresCheckpointIndex ────────────────────────────────
409
500
 
410
501