harness-sdk-python 0.36.2__py3-none-any.whl → 0.36.4__py3-none-any.whl

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/deepagents.py CHANGED
@@ -146,6 +146,8 @@ class DeepagentsMessages:
146
146
  )
147
147
  self._checkpointer = checkpointer
148
148
  self._captured: dict[str, Any] | None = None
149
+ self._admission: Any | None = None
150
+ self._input_checkpoint_seen = False
149
151
  self._ephemeral: dict[str, dict[str, Any]] = {}
150
152
  self._write_lock = asyncio.Lock()
151
153
 
@@ -190,8 +192,7 @@ class DeepagentsMessages:
190
192
  self, ctx: Any
191
193
  ) -> tuple[dict[str, Any] | None, dict[str, Any]]:
192
194
  """The dispatch's langgraph (payload, config): plans the resumption, restores a rewind's window, appends the batch to the wire state, and confirms it applied."""
193
- self._captured = None
194
- self._thread.abandon_plan()
195
+ self.abandon_turn()
195
196
  config = self.config()
196
197
  if ctx.resume:
197
198
  return None, config
@@ -206,7 +207,7 @@ class DeepagentsMessages:
206
207
  turn: list[Any] = [*plan.replay]
207
208
  for message in ctx.messages:
208
209
  turn.append(self._append_user(message))
209
- ctx.applied()
210
+ self.admit(ctx.applied())
210
211
  if plan.fork:
211
212
  if plan.checkpoint_id is not None:
212
213
  config["configurable"]["checkpoint_id"] = plan.checkpoint_id
@@ -217,11 +218,34 @@ class DeepagentsMessages:
217
218
  turn = [RemoveMessage(id=REMOVE_ALL_MESSAGES), *turn]
218
219
  return ({"messages": turn} if turn else None), config
219
220
 
221
+ def admit(self, admission: Any) -> None:
222
+ """Retain a visibly applied batch until its root checkpoint is saved."""
223
+ if self._admission is not None:
224
+ raise RuntimeError("previous input admission is still pending")
225
+ self._admission = admission
226
+
227
+ def checkpoint_saved(self, config: Any, checkpoint: Any, metadata: Any) -> None:
228
+ """Confirm this turn's input from a FrontierCheckpointer checkpoint callback."""
229
+ configurable = config["configurable"]
230
+ if (
231
+ configurable["thread_id"] == self._thread_id
232
+ and configurable.get("checkpoint_ns", "") == ""
233
+ and metadata["source"] in ("input", "loop")
234
+ ):
235
+ self._confirm_admission()
236
+
237
+ def _confirm_admission(self) -> None:
238
+ if self._admission is not None:
239
+ self._admission.dur()
240
+ self._admission = None
241
+
220
242
  def append_event(self, namespace: tuple, mode: str, event: Any) -> None:
221
243
  """Fold one ``astream`` event into the wire state; the root checkpoint stream is captured for the turn's ingest."""
222
244
  if mode == "checkpoints":
223
245
  if namespace == ():
224
246
  self._captured = event
247
+ if event["metadata"]["source"] in ("input", "loop"):
248
+ self._input_checkpoint_seen = True
225
249
  return
226
250
  from statewire.langgraph import append_langgraph_event
227
251
 
@@ -231,11 +255,15 @@ class DeepagentsMessages:
231
255
  """Ingest the turn's captured checkpoint and restamp; ``refetch`` discards the capture and reads the checkpointer's latest instead."""
232
256
  captured, self._captured = self._captured, None
233
257
  await deepagents_ingest(self._thread, captured, refetch=refetch)
258
+ if self._input_checkpoint_seen:
259
+ self._confirm_admission()
234
260
  self._restamp()
235
261
 
236
262
  def abandon_turn(self) -> None:
237
263
  """Discard a stream capture and any fork planned for the abandoned turn."""
238
264
  self._captured = None
265
+ self._admission = None
266
+ self._input_checkpoint_seen = False
239
267
  self._thread.abandon_plan()
240
268
 
241
269
  def append_users(self, messages: Any) -> list[dict[str, Any]]:
@@ -0,0 +1,78 @@
1
+ from typing import Any, AsyncIterator, Awaitable, Callable
2
+
3
+ from langgraph.checkpoint.base import BaseCheckpointSaver
4
+ from pinned import Frontier
5
+
6
+
7
+ class FrontierCheckpointer(BaseCheckpointSaver):
8
+ """Order a LangGraph saver's async writes with the owning instance's durable effects."""
9
+
10
+ def __init__(
11
+ self,
12
+ saver: BaseCheckpointSaver,
13
+ *,
14
+ frontier: Frontier,
15
+ on_checkpoint: Callable[[Any, Any, Any], None] | None = None,
16
+ ) -> None:
17
+ if isinstance(saver, FrontierCheckpointer):
18
+ raise ValueError("a checkpointer can participate in only one frontier")
19
+ super().__init__(serde=saver.serde)
20
+ self.saver = saver
21
+ self.frontier = frontier
22
+ self.on_checkpoint = on_checkpoint
23
+
24
+ @property
25
+ def config_specs(self):
26
+ return self.saver.config_specs
27
+
28
+ def get_next_version(self, current, channel):
29
+ return self.saver.get_next_version(current, channel)
30
+
31
+ async def _persist(self, effect: Callable[[], Awaitable[Any]]) -> Any:
32
+ result = None
33
+
34
+ async def write() -> None:
35
+ nonlocal result
36
+ result = await effect()
37
+
38
+ pending = write()
39
+ try:
40
+ position = self.frontier.enlist(pending)
41
+ except BaseException:
42
+ pending.close()
43
+ raise
44
+ await self.frontier.wait(position)
45
+ return result
46
+
47
+ async def aget_tuple(self, config):
48
+ await self.frontier.receipt()
49
+ return await self.saver.aget_tuple(config)
50
+
51
+ async def aget_delta_channel_history(self, *, config, channels):
52
+ await self.frontier.receipt()
53
+ return await self.saver.aget_delta_channel_history(config=config, channels=channels)
54
+
55
+ async def alist(
56
+ self, config, *, filter=None, before=None, limit=None
57
+ ) -> AsyncIterator[Any]:
58
+ await self.frontier.receipt()
59
+ async for checkpoint in self.saver.alist(
60
+ config, filter=filter, before=before, limit=limit
61
+ ):
62
+ yield checkpoint
63
+
64
+ async def aput(self, config, checkpoint, metadata, new_versions):
65
+ result = await self._persist(
66
+ lambda: self.saver.aput(config, checkpoint, metadata, new_versions)
67
+ )
68
+ if self.on_checkpoint is not None:
69
+ self.on_checkpoint(config, checkpoint, metadata)
70
+ return result
71
+
72
+ async def aput_writes(self, config, writes, task_id, task_path="") -> None:
73
+ await self._persist(
74
+ lambda: self.saver.aput_writes(config, writes, task_id, task_path)
75
+ )
76
+
77
+ async def adelete_thread(self, thread_id) -> None:
78
+ await self._persist(lambda: self.saver.adelete_thread(thread_id))
@@ -26,9 +26,10 @@ order, then takes exactly one action based on the derived status and net
26
26
  effect (rewind > send-dispatch > continue > input-resume). Every initiator applies
27
27
  and settles accepted with its state-visible application, and every verb's
28
28
  verdict follows the admission's durable flush. ``entry["dispatching"]`` is
29
- the unconfirmed batch record; ``ctx.applied()`` confirms it durably,
29
+ the visible batch record; ``ctx.applied()`` clears it at memory speed and
30
+ returns an admission whose ``dur()`` confirms consumption behind prior frontier effects,
30
31
  ``steering.take()`` may refill it via deferred durable pops — at most one
31
- unconfirmed batch ever exists. Settle consumes a confirmed batch; a batch
32
+ visibly unapplied batch exists. Admission handles retain unconfirmed batches. Settle consumes a confirmed batch; a batch
32
33
  still unconfirmed at settle returns to the front of its lanes (after a crash,
33
34
  unconfirmed taken entries re-present there on their own). A batch already
34
35
  durably confirmed redelivers in resume mode instead (``ctx.resume``): the
@@ -68,6 +69,7 @@ from dataclasses import dataclass, field
68
69
  from typing import Any, AsyncIterator, Callable, Coroutine, Iterable, Protocol
69
70
 
70
71
  from pinned import EvictionRequested
72
+ from pinned.durability import FenceLost
71
73
  from pinned.trays import Tray, TrayDescriptor, TrayCtx, TrayMapNamespace, inbox
72
74
  from pinned.trays import persistent, persistent_outbox, persistent_queue
73
75
  from statepatch import observable
@@ -396,6 +398,7 @@ class RunManager(Tray):
396
398
  self._staged_rewind: _Rewind | None = None
397
399
  self._batch: list[tuple[str, dict[str, Any]]] = []
398
400
  self._pending_takes = 0
401
+ self._admissions: list[RunManager.Admission] = []
399
402
  self._input_requests: list[dict[str, Any]] = []
400
403
  self._input_answers: dict[str, Any] = {}
401
404
  self._run_live: "RunManager.RunContext | None" = None
@@ -914,7 +917,9 @@ class RunManager(Tray):
914
917
  "run must return a RunManager outcome, got "
915
918
  f"{type(outcome).__name__}"
916
919
  )
917
- if isinstance(outcome, RunManager.Complete) and self.dispatching.value is not None:
920
+ if isinstance(outcome, RunManager.Complete) and (
921
+ self.dispatching.value is not None or self._admissions
922
+ ):
918
923
  raise RuntimeError("run returned Complete with an unconfirmed batch")
919
924
  except EvictionRequested:
920
925
  self._advance()
@@ -1028,11 +1033,33 @@ class RunManager(Tray):
1028
1033
  def _settle(self, ctx: "RunManager.RunContext") -> None:
1029
1034
  if self._run_live is ctx:
1030
1035
  self._run_live = None
1036
+ takes = self._pending_takes + sum(a._takes for a in self._admissions)
1037
+ if takes:
1038
+ self.steerQueue.untake(takes)
1039
+ self._drop_dispatched(
1040
+ message["id"]
1041
+ for admission in self._admissions
1042
+ if admission._takes
1043
+ for _, message in admission._batch
1044
+ )
1045
+ if self._pending_takes:
1046
+ self._drop_dispatched(message["id"] for _, message in self._batch)
1047
+ self._batch = []
1048
+ self._pending_takes = 0
1049
+ self._admissions.clear()
1050
+ recorded = self.outbox.value
1051
+ if recorded is not None and not self.batchApplied.value:
1052
+ item = recorded["item"]
1053
+ self._batch = list(zip(item["lanes"], item["record"]["messages"]))
1031
1054
  if self._batch:
1032
1055
  self._drop_dispatched(item["id"] for _, item in self._batch)
1033
1056
  self._unstage_batch()
1034
1057
  else:
1035
- record = self.dispatching.value
1058
+ record = (
1059
+ recorded["item"]["record"]
1060
+ if recorded is not None and not self.batchApplied.value
1061
+ else self.dispatching.value
1062
+ )
1036
1063
  if (
1037
1064
  record is not None
1038
1065
  and record["trigger"] != "steer"
@@ -1068,16 +1095,15 @@ class RunManager(Tray):
1068
1095
  self.dispatching.value = None
1069
1096
  self._finish_settle()
1070
1097
 
1071
- def _applied(self) -> None:
1098
+ def _applied(self, ctx: "RunManager.RunContext") -> "RunManager.Admission":
1072
1099
  if self.dispatching.value is None:
1073
1100
  raise RuntimeError("applied() with no unapplied batch")
1074
- if self._pending_takes:
1075
- self.confirmedTakes.value = self.confirmedTakes.value + self._pending_takes
1076
- self._pending_takes = 0
1077
- else:
1078
- self.batchApplied.value = True
1101
+ admission = RunManager.Admission(ctx, list(self._batch), self._pending_takes)
1102
+ self._admissions.append(admission)
1103
+ self._pending_takes = 0
1079
1104
  self._batch = []
1080
1105
  self.dispatching.value = None
1106
+ return admission
1081
1107
 
1082
1108
  def _unstage_batch(self) -> None:
1083
1109
  taken, self._batch = self._batch, []
@@ -1600,6 +1626,29 @@ class RunManager(Tray):
1600
1626
  for item in items
1601
1627
  )
1602
1628
 
1629
+ @dataclass(eq=False)
1630
+ class Admission:
1631
+ """One visibly applied batch; ``dur()`` orders its consumption behind enlisted checkpoint writes."""
1632
+
1633
+ _ctx: "RunManager.RunContext"
1634
+ _batch: list[tuple[str, dict[str, Any]]]
1635
+ _takes: int
1636
+ _confirmed: bool = False
1637
+
1638
+ def dur(self) -> None:
1639
+ """Enlist this batch's durable consumption; idempotent until the dispatch settles."""
1640
+ self._ctx._ensure_active()
1641
+ if self._confirmed:
1642
+ return
1643
+ self._confirmed = True
1644
+ manager = self._ctx._manager
1645
+ while manager._admissions and manager._admissions[0]._confirmed:
1646
+ admission = manager._admissions.pop(0)
1647
+ if admission._takes:
1648
+ manager.confirmedTakes.value += admission._takes
1649
+ else:
1650
+ manager.batchApplied.value = True
1651
+
1603
1652
  @dataclass(frozen=True, eq=False)
1604
1653
  class RunContext:
1605
1654
  trigger: str
@@ -1691,10 +1740,10 @@ class RunManager(Tray):
1691
1740
  if self._manager._run_live is not self:
1692
1741
  raise RuntimeError("this run has already settled")
1693
1742
 
1694
- def applied(self) -> None:
1695
- """Durably confirms the current batch: clears the dispatch record so settle consumes it."""
1743
+ def applied(self) -> "RunManager.Admission":
1744
+ """Clear the visible dispatch immediately; call the returned admission's ``dur()`` after enlisting its checkpoint."""
1696
1745
  self._ensure_active()
1697
- self._manager._applied()
1746
+ return self._manager._applied(self)
1698
1747
 
1699
1748
 
1700
1749
  class RunManagerNamespace(TrayMapNamespace):
@@ -1709,6 +1758,8 @@ class RunManagerNamespace(TrayMapNamespace):
1709
1758
  "(RunManager.Thread) to host @run_manager"
1710
1759
  )
1711
1760
  self._mint: "asyncio.Task[Any] | None" = None
1761
+ self._loading: "asyncio.Task[None] | None" = None
1762
+ self._loaded = False
1712
1763
  self._mounted: list[RunManager] = []
1713
1764
  api.state["runs"] = []
1714
1765
  api.state["status"] = "ready"
@@ -1725,7 +1776,103 @@ class RunManagerNamespace(TrayMapNamespace):
1725
1776
  tray, _minted = await self._machine(run_id, principal)
1726
1777
  return tray
1727
1778
 
1779
+ async def _load_existing(self) -> None:
1780
+ if self._loaded:
1781
+ return
1782
+ if self._loading is None:
1783
+ self._loading = self._api.create_task(self._restore_existing())
1784
+ await asyncio.shield(self._loading)
1785
+
1786
+ async def _restore_existing(self) -> None:
1787
+ durability = self._durability()
1788
+ try:
1789
+ rows = await durability.store.list(
1790
+ durability.ns, durability.id, durability.epoch, tray_ns=self._spec.name
1791
+ )
1792
+ except FenceLost:
1793
+ self._api.abort()
1794
+ raise
1795
+ for _, run_id, marked, doc in rows:
1796
+ await self._obtain(run_id, create=False, row=(marked, doc))
1797
+ self._loaded = True
1798
+
1799
+ async def maybe_sync(
1800
+ self,
1801
+ *,
1802
+ pending_inputs: Iterable[dict[str, Any]],
1803
+ has_pending_tasks: bool,
1804
+ principal: Any,
1805
+ meta: Any = None,
1806
+ ) -> bool:
1807
+ """Import external pending work into an inactive run; return whether its state changed."""
1808
+ if type(has_pending_tasks) is not bool:
1809
+ raise TypeError("has_pending_tasks must be a bool")
1810
+ requests = tuple(pending_inputs)
1811
+ if requests:
1812
+ RunManager.InputRequired(requests)
1813
+ await self._load_existing()
1814
+ if self._mint is not None:
1815
+ await asyncio.shield(self._mint)
1816
+ tray = self.live()
1817
+ if tray is None:
1818
+ if not requests and not has_pending_tasks:
1819
+ return False
1820
+ tray, _ = await self._machine(self._api.domain.token_hex(16), principal)
1821
+ tray._check_run_owner(principal)
1822
+ if (
1823
+ tray._in_flight()
1824
+ or tray._intake
1825
+ or tray._batch
1826
+ or tray.queue.value
1827
+ or tray.steerQueue.value
1828
+ or tray.nextDispatch.value is not None
1829
+ or tray._staged_rewind is not None
1830
+ or tray.outcome.value is not None
1831
+ ):
1832
+ return False
1833
+ previous = {r["id"]: r for r in tray.inputRequests.value or ()}
1834
+ synchronized = []
1835
+ for request in requests:
1836
+ item = {k: v for k, v in request.items() if k not in ("response", "meta")}
1837
+ old = previous.get(request["id"], {})
1838
+ if "response" in old:
1839
+ item.update(response=old["response"], meta=old.get("meta"))
1840
+ synchronized.append(item)
1841
+ if requests:
1842
+ if (
1843
+ tray.inputRequests.value == synchronized
1844
+ and tray.halted.value is None
1845
+ and tray.error.value is None
1846
+ ):
1847
+ return False
1848
+ tray.inputRequests.value = synchronized
1849
+ tray._input_requests = [
1850
+ {k: v for k, v in r.items() if k not in ("response", "meta")}
1851
+ for r in synchronized
1852
+ ]
1853
+ tray._input_answers = {
1854
+ r["id"]: (r["response"], r.get("meta"))
1855
+ for r in synchronized if "response" in r
1856
+ }
1857
+ tray.halted.value = None
1858
+ tray.error.value = None
1859
+ tray._publish_status()
1860
+ tray._schedule(tray._advance)
1861
+ return True
1862
+ changed = bool(tray.inputRequests.value)
1863
+ tray._clear_input()
1864
+ if has_pending_tasks:
1865
+ tray._dispatch("stop-continue", [], root_meta=meta)
1866
+ tray.batchApplied.value = True
1867
+ tray.dispatching.value = None
1868
+ tray._publish_status()
1869
+ return True
1870
+ if changed:
1871
+ tray._publish_status()
1872
+ return changed
1873
+
1728
1874
  async def _machine(self, run_id: str, principal: Any) -> tuple[RunManager, bool]:
1875
+ await self._load_existing()
1729
1876
  # No await between the live() check and the mint's publication, and no
1730
1877
  # lock held across storage I/O: same-tick callers await the shared mint
1731
1878
  # task and merge into the machine it mounts, or raise its failure.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.36.2
3
+ Version: 0.36.4
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
7
7
  Requires-Python: <4.0,>=3.12
8
- Requires-Dist: pinned<0.16,>=0.15.1
9
- Requires-Dist: statewire<0.18,>=0.17.2
8
+ Requires-Dist: pinned<0.16,>=0.15.2
9
+ Requires-Dist: statewire<0.18,>=0.17.4
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
@@ -1,11 +1,12 @@
1
1
  harness_sdk/__init__.py,sha256=zoABjY3ixYzh5YyWtHQKs3MjphMJrwklzV0LOacDA5c,1740
2
2
  harness_sdk/checkpoints.py,sha256=4Jv0eyjGoUv9lC6ZZSOpHmZ4j_vMFkFPSxFcrpJ-NTQ,28195
3
- harness_sdk/deepagents.py,sha256=3iWadYjG-Yv0fli4x8s5n3jOTs11JHS2cK6tj8foyvk,14520
3
+ harness_sdk/deepagents.py,sha256=ilrz32oEhvoiKkKGPGbmsTmjZvWomhg1roxPMMxSCXk,15745
4
4
  harness_sdk/fenced_postgres.py,sha256=hbbrMR1WaqrHTNgd4wFrXfRrzQFGnyim-c8nZXgwWp4,13235
5
+ harness_sdk/langgraph.py,sha256=0yT3l7wWrxjG4SOMQQAjre-EK2JBndryAmBg-k8NESg,2680
5
6
  harness_sdk/linear_thread.py,sha256=laDRXKLFsMlyWS4snqNAM6t38wO26gevLWXRNivhHSM,1810
6
7
  harness_sdk/protocol.py,sha256=4diqaWeYIVEKRjyKkqzeMMHJb7zrOQCvPkjQzkeIku8,140
7
- harness_sdk/run_manager.py,sha256=gcd4nfXJ8ssD-v0yP2FF4WsUE-xWzgrChdG4RQV_UqA,82165
8
+ harness_sdk/run_manager.py,sha256=wKhMHscBUtAT4qddHC6_mftGO3u9T3HivlUgwfmJCAk,88062
8
9
  harness_sdk/voice.py,sha256=bRloaKSS1d0mFvfIM4Bhp50ADBK_7GpdarGvmDBubjg,32370
9
- harness_sdk_python-0.36.2.dist-info/METADATA,sha256=aJPyd884khPDjcycx20eL8ZrLZSw1voyTkoIEajTCKs,2488
10
- harness_sdk_python-0.36.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
- harness_sdk_python-0.36.2.dist-info/RECORD,,
10
+ harness_sdk_python-0.36.4.dist-info/METADATA,sha256=s8ACi-pVJMePCNS9BK75tFL7umZJxu0YYo7B7sI3sCA,2488
11
+ harness_sdk_python-0.36.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ harness_sdk_python-0.36.4.dist-info/RECORD,,