harness-sdk-python 0.22.0__tar.gz → 0.23.1__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.
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/PKG-INFO +1 -1
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/pyproject.toml +1 -1
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/src/harness_sdk/__init__.py +2 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/src/harness_sdk/checkpoints.py +63 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_checkpoints.py +91 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/.gitignore +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/README.md +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/examples/__init__.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.22.0 → harness_sdk_python-0.23.1}/tests/test_stopping_window.py +0 -0
|
@@ -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,24 @@ 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, list[int]]:
|
|
386
|
+
"""``message_id``'s own branch id and its sibling fan-out's branch ids in creation order."""
|
|
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
|
+
return branch, sorted({e["branch_id"] for e in entries} | {branch})
|
|
391
|
+
|
|
354
392
|
async def plan(self, trigger: str, *, rollback_to: str | None = None) -> Plan:
|
|
355
393
|
"""The resumption for ``trigger``; a fork trigger names its last surviving message via ``rollback_to``."""
|
|
356
394
|
if trigger in _TIP_TRIGGERS:
|
|
@@ -471,6 +509,31 @@ class CheckpointThread:
|
|
|
471
509
|
"""Load the branch's subtree tip as the current window."""
|
|
472
510
|
return self._load(await self._get(self._tip_of(branch_id)))
|
|
473
511
|
|
|
512
|
+
async def _world_path(self, branch_id: int) -> list[int]:
|
|
513
|
+
path = [0]
|
|
514
|
+
while branch_id != 0:
|
|
515
|
+
path.insert(1, branch_id)
|
|
516
|
+
fork_id = self.branches[branch_id - 1]["fork_message_id"]
|
|
517
|
+
if fork_id is None:
|
|
518
|
+
break
|
|
519
|
+
row = await self._index.get_message(self._thread_id, self._ns, fork_id)
|
|
520
|
+
if row is None:
|
|
521
|
+
raise KeyError(f"fork message {fork_id!r} is not indexed")
|
|
522
|
+
branch_id = row["branch_id"]
|
|
523
|
+
return path
|
|
524
|
+
|
|
525
|
+
async def switch_state(
|
|
526
|
+
self, branch_id: int
|
|
527
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
528
|
+
"""Switch like ``switch_branch`` and return (transcript, lineage) for the host to write back into graph state."""
|
|
529
|
+
await self.switch_branch(branch_id)
|
|
530
|
+
lineage = self.lineage or {
|
|
531
|
+
"branch_ids": await self._world_path(branch_id),
|
|
532
|
+
"parent_checkpoint_id": None,
|
|
533
|
+
}
|
|
534
|
+
self._path = list(lineage["branch_ids"])
|
|
535
|
+
return list(self.transcript), lineage
|
|
536
|
+
|
|
474
537
|
async def page_up(self) -> "CheckpointThread":
|
|
475
538
|
"""Load the parent world's tail checkpoint as the current window."""
|
|
476
539
|
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_lists_the_fan_out_ids_in_creation_order():
|
|
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, [1, 2, 3])
|
|
252
|
+
assert thread.sibling_info("u2") == (0, [0])
|
|
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, [1, 2, 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") == (0, [0, 1]) # branch 0 before the edit fork
|
|
266
|
+
assert thread.sibling_info("u1") == (0, [0])
|
|
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, [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
|
|
|
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
|
|
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
|
|
File without changes
|