harness-sdk-python 0.15.0__tar.gz → 0.17.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.
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/.gitignore +1 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/PKG-INFO +3 -2
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/examples/runs_app.py +21 -24
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/pyproject.toml +3 -2
- harness_sdk_python-0.17.0/src/harness_sdk/__init__.py +9 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/src/harness_sdk/fenced_postgres.py +97 -14
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/src/harness_sdk/run_manager.py +481 -342
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/run_helpers.py +40 -32
- harness_sdk_python-0.17.0/tests/test_durability.py +306 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_enqueue.py +3 -11
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_fenced_postgres.py +196 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_input_required.py +8 -8
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_outcomes.py +33 -32
- harness_sdk_python-0.17.0/tests/test_restore.py +124 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_rewind_during_run.py +2 -10
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_settle.py +28 -4
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_stop_continue.py +6 -5
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_stopping_window.py +37 -29
- harness_sdk_python-0.15.0/src/harness_sdk/__init__.py +0 -4
- harness_sdk_python-0.15.0/tests/test_restore.py +0 -177
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/README.md +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_steer.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.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
|
|
7
7
|
Requires-Python: <4.0,>=3.12
|
|
8
|
-
Requires-Dist:
|
|
8
|
+
Requires-Dist: pinned<0.12,>=0.11.0
|
|
9
|
+
Requires-Dist: statewire<0.10,>=0.9.0
|
|
9
10
|
Provides-Extra: deepagents
|
|
10
11
|
Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
|
|
11
12
|
Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
|
|
@@ -19,11 +19,12 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from fastapi import FastAPI
|
|
21
21
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
22
|
-
from pinned import PinnedHost
|
|
22
|
+
from pinned import PinnedHost, PinnedWorker
|
|
23
|
+
from pinned.testing import MemoryStateStore
|
|
23
24
|
from statewire import Statewire, command, plain
|
|
24
25
|
from statewire.langgraph import append_langgraph_event
|
|
25
26
|
|
|
26
|
-
from harness_sdk import RunManager, linear_thread
|
|
27
|
+
from harness_sdk import RunManager, linear_thread, run_manager
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def select_model() -> str:
|
|
@@ -81,19 +82,15 @@ class RunsHost(Statewire):
|
|
|
81
82
|
role=lambda m: "user" if m["type"] == "human" else "assistant",
|
|
82
83
|
)
|
|
83
84
|
self.turn = 0
|
|
84
|
-
self.runs = RunManager(
|
|
85
|
-
state=self.state,
|
|
86
|
-
run=self.run,
|
|
87
|
-
thread=self.thread,
|
|
88
|
-
create_task=self.create_task,
|
|
89
|
-
schedule=self.schedule,
|
|
90
|
-
capabilities=("rewind",),
|
|
91
|
-
)
|
|
92
85
|
yield
|
|
93
86
|
|
|
87
|
+
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
88
|
+
return await self.thread.get_message_meta(message_id)
|
|
89
|
+
|
|
94
90
|
async def get_message_child_id(self, parent_id: str) -> str | None:
|
|
95
91
|
return await self.thread.get_message_child_id(parent_id)
|
|
96
92
|
|
|
93
|
+
@run_manager(capabilities=("rewind",))
|
|
97
94
|
async def run(self, ctx: RunManager.RunContext) -> Any:
|
|
98
95
|
if ctx.has_rollback:
|
|
99
96
|
self.truncate(ctx.rollback_to)
|
|
@@ -158,44 +155,44 @@ class RunsHost(Statewire):
|
|
|
158
155
|
|
|
159
156
|
@command("run/enqueue")
|
|
160
157
|
async def run_enqueue(self, params, *, ctx):
|
|
161
|
-
return await self.
|
|
158
|
+
return await (await self.run.get()).enqueue(params, applied=ctx.applied)
|
|
162
159
|
|
|
163
160
|
@command("run/steer")
|
|
164
161
|
async def run_steer(self, params, *, ctx):
|
|
165
|
-
return await self.
|
|
162
|
+
return await (await self.run.get()).steer(params, applied=ctx.applied)
|
|
166
163
|
|
|
167
164
|
@command("run/dequeue")
|
|
168
165
|
async def run_dequeue(self, params):
|
|
169
|
-
return await self.
|
|
166
|
+
return await (await self.run.get()).dequeue(params)
|
|
170
167
|
|
|
171
168
|
@command("run/edit")
|
|
172
169
|
async def run_edit(self, params, *, ctx):
|
|
173
|
-
return await self.
|
|
170
|
+
return await (await self.run.get()).edit(params, applied=ctx.applied)
|
|
174
171
|
|
|
175
172
|
@command("run/reload")
|
|
176
173
|
async def run_reload(self, params, *, ctx):
|
|
177
|
-
return await self.
|
|
174
|
+
return await (await self.run.get()).reload(params, applied=ctx.applied)
|
|
178
175
|
|
|
179
176
|
@command("run/stop")
|
|
180
177
|
async def run_stop(self, params=None, *, ctx):
|
|
181
|
-
return await self.
|
|
178
|
+
return await (await self.run.get()).stop(params, applied=ctx.applied)
|
|
182
179
|
|
|
183
180
|
@command("run/continue")
|
|
184
181
|
async def run_continue(self, params=None, *, ctx):
|
|
185
|
-
return await self.
|
|
182
|
+
return await (await self.run.get()).continue_run(applied=ctx.applied)
|
|
186
183
|
|
|
187
184
|
@command("run/input")
|
|
188
185
|
async def run_input(self, params):
|
|
189
|
-
return await self.
|
|
186
|
+
return await (await self.run.get()).input(params)
|
|
190
187
|
|
|
191
188
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
pinboard_url=_pinboard_url,
|
|
189
|
+
worker = PinnedWorker(
|
|
190
|
+
state_store=MemoryStateStore(),
|
|
191
|
+
pinboard_url=os.environ.get("PINBOARD_URL"),
|
|
196
192
|
token=os.environ.get("PINBOARD_TOKEN"),
|
|
197
193
|
advertise_url=os.environ.get("ADVERTISE_URL"),
|
|
198
|
-
namespace="/threads" if _pinboard_url else None,
|
|
199
194
|
)
|
|
195
|
+
router = PinnedHost(RunsHost, worker=worker, namespace="/threads")
|
|
200
196
|
app = FastAPI(lifespan=router.lifespan)
|
|
201
|
-
app.include_router(
|
|
197
|
+
app.include_router(worker)
|
|
198
|
+
app.include_router(router)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.17.0"
|
|
4
4
|
description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.12,<4.0"
|
|
8
|
-
dependencies = ["statewire>=0.
|
|
8
|
+
dependencies = ["statewire>=0.9.0,<0.10", "pinned>=0.11.0,<0.12"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -22,6 +22,7 @@ Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
|
22
22
|
|
|
23
23
|
[tool.uv.sources]
|
|
24
24
|
statewire = { workspace = true }
|
|
25
|
+
pinned = { workspace = true }
|
|
25
26
|
|
|
26
27
|
[build-system]
|
|
27
28
|
requires = ["hatchling"]
|
|
@@ -86,6 +86,21 @@ def _bump_sql(fence_table: str) -> SQL:
|
|
|
86
86
|
).format(t=Identifier(fence_table))
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def _adopt_sql(fence_table: str) -> SQL:
|
|
90
|
+
return SQL(
|
|
91
|
+
"INSERT INTO {t} (thread_id, epoch) VALUES (%s, %s) "
|
|
92
|
+
"ON CONFLICT (thread_id) DO UPDATE SET epoch = excluded.epoch "
|
|
93
|
+
"WHERE {t}.epoch <= excluded.epoch "
|
|
94
|
+
"RETURNING epoch"
|
|
95
|
+
).format(t=Identifier(fence_table))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _require_epoch(epoch: int) -> int:
|
|
99
|
+
if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0:
|
|
100
|
+
raise ValueError(f"epoch must be a non-negative integer, got {epoch!r}")
|
|
101
|
+
return epoch
|
|
102
|
+
|
|
103
|
+
|
|
89
104
|
def _assert_sql(fence_table: str) -> SQL:
|
|
90
105
|
return SQL("SELECT {}(%s, %s)").format(Identifier(_assert_fn(fence_table)))
|
|
91
106
|
|
|
@@ -125,30 +140,43 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
125
140
|
self.fence_table = _require_fence_table(fence_table)
|
|
126
141
|
self.on_fence_lost = on_fence_lost
|
|
127
142
|
self._fence_lost_notified = False
|
|
143
|
+
self._fence_lost_terminal = False
|
|
128
144
|
|
|
129
145
|
@staticmethod
|
|
130
|
-
async def
|
|
146
|
+
async def _ensure_fence_objects(
|
|
131
147
|
cur: "AsyncCursor[DictRow]",
|
|
132
|
-
thread_id: str,
|
|
133
148
|
*,
|
|
134
149
|
create_fence_table: bool,
|
|
135
150
|
fence_table: str,
|
|
136
|
-
) ->
|
|
151
|
+
) -> None:
|
|
137
152
|
if create_fence_table:
|
|
138
153
|
await cur.execute(_create_sql(fence_table))
|
|
139
154
|
await cur.execute(_create_fn_sql(fence_table))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
return
|
|
156
|
+
await cur.execute(
|
|
157
|
+
"SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
|
|
158
|
+
(fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
|
|
159
|
+
)
|
|
160
|
+
row = await cur.fetchone()
|
|
161
|
+
if row["tbl"] is None:
|
|
162
|
+
raise _missing_fence_error(f"fence table {fence_table!r}")
|
|
163
|
+
if row["fn"] is None:
|
|
164
|
+
raise _missing_fence_error(
|
|
165
|
+
f"fence function {_assert_fn(fence_table)!r}"
|
|
144
166
|
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
async def _validate_and_bump(
|
|
170
|
+
cls,
|
|
171
|
+
cur: "AsyncCursor[DictRow]",
|
|
172
|
+
thread_id: str,
|
|
173
|
+
*,
|
|
174
|
+
create_fence_table: bool,
|
|
175
|
+
fence_table: str,
|
|
176
|
+
) -> int:
|
|
177
|
+
await cls._ensure_fence_objects(
|
|
178
|
+
cur, create_fence_table=create_fence_table, fence_table=fence_table
|
|
179
|
+
)
|
|
152
180
|
await cur.execute(_bump_sql(fence_table), (thread_id,))
|
|
153
181
|
return (await cur.fetchone())["epoch"]
|
|
154
182
|
|
|
@@ -218,7 +246,54 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
218
246
|
on_fence_lost=on_fence_lost,
|
|
219
247
|
)
|
|
220
248
|
|
|
249
|
+
@classmethod
|
|
250
|
+
async def adopt(
|
|
251
|
+
cls,
|
|
252
|
+
conn: _ainternal.Conn,
|
|
253
|
+
thread_id: str,
|
|
254
|
+
epoch: int,
|
|
255
|
+
*,
|
|
256
|
+
create_fence_table: bool,
|
|
257
|
+
pipe: AsyncPipeline | None = None,
|
|
258
|
+
serde: SerializerProtocol | None = None,
|
|
259
|
+
fence_table: str = DEFAULT_FENCE_TABLE,
|
|
260
|
+
on_fence_lost: Callable[[], None] | None = None,
|
|
261
|
+
) -> "AsyncFencedPostgresSaver":
|
|
262
|
+
"""Install a caller-owned epoch (the placement epoch) into the fence row, so one epoch fences the tray store and the checkpoints; a newer stored epoch raises ``FenceLost``."""
|
|
263
|
+
_require_thread_id(thread_id)
|
|
264
|
+
_require_epoch(epoch)
|
|
265
|
+
_require_fence_table(fence_table)
|
|
266
|
+
async with _ainternal.get_connection(conn) as c:
|
|
267
|
+
if c.info.transaction_status != TransactionStatus.IDLE:
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
"adopt requires a connection with no transaction in progress"
|
|
270
|
+
)
|
|
271
|
+
async with (
|
|
272
|
+
c.transaction(),
|
|
273
|
+
c.cursor(binary=True, row_factory=dict_row) as cur,
|
|
274
|
+
):
|
|
275
|
+
await cls._ensure_fence_objects(
|
|
276
|
+
cur,
|
|
277
|
+
create_fence_table=create_fence_table,
|
|
278
|
+
fence_table=fence_table,
|
|
279
|
+
)
|
|
280
|
+
await cur.execute(_adopt_sql(fence_table), (thread_id, epoch))
|
|
281
|
+
if await cur.fetchone() is None:
|
|
282
|
+
raise FenceLost(thread_id, epoch)
|
|
283
|
+
if pipe is not None:
|
|
284
|
+
await pipe.sync()
|
|
285
|
+
return cls(
|
|
286
|
+
conn,
|
|
287
|
+
thread_id,
|
|
288
|
+
epoch,
|
|
289
|
+
pipe=pipe,
|
|
290
|
+
serde=serde,
|
|
291
|
+
fence_table=fence_table,
|
|
292
|
+
on_fence_lost=on_fence_lost,
|
|
293
|
+
)
|
|
294
|
+
|
|
221
295
|
def _fence_lost(self) -> FenceLost:
|
|
296
|
+
self._fence_lost_terminal = True
|
|
222
297
|
if self.on_fence_lost is not None and not self._fence_lost_notified:
|
|
223
298
|
self._fence_lost_notified = True
|
|
224
299
|
self.on_fence_lost()
|
|
@@ -262,6 +337,12 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
262
337
|
row = await cur.fetchone()
|
|
263
338
|
return row is None or row["epoch"] != self.epoch
|
|
264
339
|
|
|
340
|
+
# Fence loss is terminal: the epoch can never become current again, so
|
|
341
|
+
# later writes fail before serializing or round-tripping to Postgres.
|
|
342
|
+
def _raise_if_fence_lost(self) -> None:
|
|
343
|
+
if self._fence_lost_terminal:
|
|
344
|
+
raise FenceLost(self.thread_id, self.epoch)
|
|
345
|
+
|
|
265
346
|
async def aput(
|
|
266
347
|
self,
|
|
267
348
|
config: RunnableConfig,
|
|
@@ -269,6 +350,7 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
269
350
|
metadata: Any,
|
|
270
351
|
new_versions: Any,
|
|
271
352
|
) -> RunnableConfig:
|
|
353
|
+
self._raise_if_fence_lost()
|
|
272
354
|
self._bound_to(config["configurable"]["thread_id"])
|
|
273
355
|
return await super().aput(config, checkpoint, metadata, new_versions)
|
|
274
356
|
|
|
@@ -279,6 +361,7 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
279
361
|
task_id: str,
|
|
280
362
|
task_path: str = "",
|
|
281
363
|
) -> None:
|
|
364
|
+
self._raise_if_fence_lost()
|
|
282
365
|
self._bound_to(config["configurable"]["thread_id"])
|
|
283
366
|
await super().aput_writes(config, writes, task_id, task_path)
|
|
284
367
|
|