harness-sdk-python 0.10.2__tar.gz → 0.12.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.10.2 → harness_sdk_python-0.12.0}/PKG-INFO +2 -2
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/pyproject.toml +2 -2
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/src/harness_sdk/run_manager.py +59 -13
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_branch_anchor.py +35 -0
- harness_sdk_python-0.12.0/tests/test_restore.py +136 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/.gitignore +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/README.md +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_ack_visibility.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.10.2 → harness_sdk_python-0.12.0}/tests/test_stop_continue.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.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.11
|
|
8
|
-
Requires-Dist: statewire<0.
|
|
8
|
+
Requires-Dist: statewire<0.7,>=0.6.0
|
|
9
9
|
Provides-Extra: deepagents
|
|
10
10
|
Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
|
|
11
11
|
Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.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.11,<4.0"
|
|
8
|
-
dependencies = ["statewire>=0.
|
|
8
|
+
dependencies = ["statewire>=0.6.0,<0.7"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -215,18 +215,60 @@ class RunManager:
|
|
|
215
215
|
return self._idle
|
|
216
216
|
|
|
217
217
|
def _init_state(self) -> None:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
218
|
+
runs = plain(self._state.get("runs"))
|
|
219
|
+
if runs is None or runs == []:
|
|
220
|
+
self._state["status"] = "ready"
|
|
221
|
+
self._state["runs"] = []
|
|
222
|
+
for key in (
|
|
223
|
+
"error",
|
|
224
|
+
"queue",
|
|
225
|
+
"steerQueue",
|
|
226
|
+
"runId",
|
|
227
|
+
"dispatch",
|
|
228
|
+
"inputRequests",
|
|
229
|
+
):
|
|
230
|
+
self._state.pop(key, None)
|
|
231
|
+
return
|
|
232
|
+
self._hydrate(runs)
|
|
233
|
+
|
|
234
|
+
def _hydrate(self, runs: Any) -> None:
|
|
235
|
+
if not isinstance(runs, list) or len(runs) != 1:
|
|
236
|
+
raise ValueError("state['runs'] must hold exactly one entry to restore")
|
|
237
|
+
entry = runs[0]
|
|
238
|
+
if not isinstance(entry, dict):
|
|
239
|
+
raise ValueError("the restored run entry must be an object")
|
|
240
|
+
if entry.get("status") != "input-required":
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"only an input-required entry restores, "
|
|
243
|
+
f"got status {entry.get('status')!r}"
|
|
244
|
+
)
|
|
245
|
+
run_id = entry.get("runId")
|
|
246
|
+
if not isinstance(run_id, str) or run_id == "":
|
|
247
|
+
raise ValueError("the restored runId must be a non-empty string")
|
|
248
|
+
for lane in ("queue", "steerQueue"):
|
|
249
|
+
if not isinstance(entry.get(lane), list):
|
|
250
|
+
raise ValueError(f"the restored {lane} must be a list")
|
|
251
|
+
for key in ("dispatching", "error"):
|
|
252
|
+
if entry.get(key, _ABSENT) is not None:
|
|
253
|
+
raise ValueError(f"the restored entry must carry {key}: None")
|
|
254
|
+
requests = entry.get("inputRequests")
|
|
255
|
+
if not isinstance(requests, list) or not requests:
|
|
256
|
+
raise ValueError("the restored inputRequests must be a non-empty list")
|
|
257
|
+
for request in requests:
|
|
258
|
+
if not isinstance(request, dict):
|
|
259
|
+
raise ValueError("restored input requests must be objects")
|
|
260
|
+
if request.get("response") is not None:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"restored input requests must carry response: None"
|
|
263
|
+
)
|
|
264
|
+
outcome = RunManager.InputRequired(
|
|
265
|
+
tuple(
|
|
266
|
+
{k: v for k, v in request.items() if k != "response"}
|
|
267
|
+
for request in requests
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
self._input_requests = [dict(r) for r in outcome.requests]
|
|
271
|
+
self._state["status"] = "input-required"
|
|
230
272
|
|
|
231
273
|
# ─── State access ───────────────────────────────────────
|
|
232
274
|
|
|
@@ -512,7 +554,11 @@ class RunManager:
|
|
|
512
554
|
self._task = None
|
|
513
555
|
self._dispatch_record = None
|
|
514
556
|
self._dispatching = []
|
|
515
|
-
self._ensure_entry()
|
|
557
|
+
entry = self._ensure_entry()
|
|
558
|
+
record = plain(entry["dispatching"])
|
|
559
|
+
if record is not None and record["trigger"] != "steer" and record["messages"]:
|
|
560
|
+
self._dispatched_ids = ()
|
|
561
|
+
entry["dispatching"] = None
|
|
516
562
|
|
|
517
563
|
def _ack(self) -> None:
|
|
518
564
|
entry = self._entry()
|
|
@@ -128,6 +128,41 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
|
|
|
128
128
|
assert res["type"] == "pending"
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
async def test_unacked_edit_crash_invalidates_the_replacement_anchor():
|
|
132
|
+
script = Script()
|
|
133
|
+
script.thread["u1"] = {
|
|
134
|
+
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
|
|
135
|
+
}
|
|
136
|
+
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
137
|
+
drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
|
|
138
|
+
(await script.next_call()).fail(RuntimeError("boom"))
|
|
139
|
+
await drv.wait_status("error")
|
|
140
|
+
assert_rejected(
|
|
141
|
+
await drv.command("run/enqueue", add("q1", anchor="u2"), terminal=False),
|
|
142
|
+
"unknown-id",
|
|
143
|
+
)
|
|
144
|
+
drv.post("run/enqueue", add("q1", anchor="u1"))
|
|
145
|
+
call = await script.next_call()
|
|
146
|
+
assert [m["id"] for m in call.ctx.messages] == ["q1"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_unacked_recovery_run_keeps_acked_dispatch_anchors():
|
|
150
|
+
script = Script()
|
|
151
|
+
async with run_host(script, capabilities=("incomplete-continuation",)) as (drv, host):
|
|
152
|
+
drv.post("run/enqueue", add("m1"))
|
|
153
|
+
call = await script.next_call()
|
|
154
|
+
call.ack()
|
|
155
|
+
call.fail(RuntimeError("boom"))
|
|
156
|
+
await drv.wait_status("error")
|
|
157
|
+
await drv.command("run/continue", terminal=False)
|
|
158
|
+
continued = await script.next_call()
|
|
159
|
+
assert continued.ctx.trigger == "error-continue"
|
|
160
|
+
continued.fail(RuntimeError("boom"))
|
|
161
|
+
await drv.wait_status("error")
|
|
162
|
+
res = await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
|
|
163
|
+
assert res["type"] == "pending"
|
|
164
|
+
|
|
165
|
+
|
|
131
166
|
async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
|
|
132
167
|
script = Script()
|
|
133
168
|
async with run_host(script) as (drv, host):
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Contract: restore across eviction. Construction hydrates a pre-existing
|
|
2
|
+
input-required run entry from state — the host writes the parked entry before
|
|
3
|
+
constructing RunManager — and the resume paths (answer all, steer) work as if
|
|
4
|
+
the park never left memory. Empty runs state seeds ready; any other shape
|
|
5
|
+
raises."""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from run_helpers import Script, add, msg, run_host
|
|
11
|
+
|
|
12
|
+
from harness_sdk import RunManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tool_call(id: str, tool_call_id: str = "tc1") -> dict:
|
|
16
|
+
return {"type": "tool-call", "id": id, "toolCallId": tool_call_id}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parked_entry(**overrides) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"runId": "run-1",
|
|
22
|
+
"status": "input-required",
|
|
23
|
+
"queue": [],
|
|
24
|
+
"steerQueue": [],
|
|
25
|
+
"dispatching": None,
|
|
26
|
+
"error": None,
|
|
27
|
+
"inputRequests": [{**tool_call("r1"), "response": None}],
|
|
28
|
+
**overrides,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def construct(state: dict) -> RunManager:
|
|
33
|
+
script = Script()
|
|
34
|
+
return RunManager(
|
|
35
|
+
state=state,
|
|
36
|
+
run=script.run,
|
|
37
|
+
thread=script,
|
|
38
|
+
create_task=asyncio.ensure_future,
|
|
39
|
+
schedule=lambda fn: fn(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_empty_runs_state_seeds_ready():
|
|
44
|
+
script = Script()
|
|
45
|
+
stale = {"status": "input-required", "runs": []}
|
|
46
|
+
async with run_host(script, initial_runs=stale) as (drv, host):
|
|
47
|
+
assert drv.replica == {"status": "ready", "runs": []}
|
|
48
|
+
script.no_call()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_hydrated_entry_rides_the_attach_snapshot():
|
|
52
|
+
script = Script()
|
|
53
|
+
async with run_host(script, initial_runs={"runs": [parked_entry()]}) as (
|
|
54
|
+
drv,
|
|
55
|
+
host,
|
|
56
|
+
):
|
|
57
|
+
assert drv.replica["status"] == "input-required"
|
|
58
|
+
assert drv.run == parked_entry()
|
|
59
|
+
script.no_call()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_answering_all_hydrated_requests_resumes():
|
|
63
|
+
script = Script()
|
|
64
|
+
entry = parked_entry(queue=[msg("m2")])
|
|
65
|
+
async with run_host(script, initial_runs={"runs": [entry]}) as (drv, host):
|
|
66
|
+
res = await drv.command(
|
|
67
|
+
"run/input", {"requestId": "r1", "response": {"output": "ok"}}
|
|
68
|
+
)
|
|
69
|
+
assert res["type"] == "accepted"
|
|
70
|
+
resumed = await script.next_call()
|
|
71
|
+
assert resumed.ctx.trigger == "input-resume"
|
|
72
|
+
assert resumed.ctx.messages == ()
|
|
73
|
+
assert resumed.ctx.input_outcomes == (
|
|
74
|
+
(tool_call("r1"), {"output": "ok"}, None),
|
|
75
|
+
)
|
|
76
|
+
assert drv.run["runId"] == "run-1"
|
|
77
|
+
resumed.ack()
|
|
78
|
+
resumed.finish(RunManager.Complete())
|
|
79
|
+
drain = await script.next_call()
|
|
80
|
+
assert [m["id"] for m in drain.ctx.messages] == ["m2"]
|
|
81
|
+
drain.ack()
|
|
82
|
+
drain.finish(RunManager.Complete())
|
|
83
|
+
await drv.wait_status("ready")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def test_steer_while_hydrated_abandons_input_and_dispatches():
|
|
87
|
+
script = Script()
|
|
88
|
+
async with run_host(script, initial_runs={"runs": [parked_entry()]}) as (
|
|
89
|
+
drv,
|
|
90
|
+
host,
|
|
91
|
+
):
|
|
92
|
+
drv.post("run/steer", add("s1"))
|
|
93
|
+
run = await script.next_call()
|
|
94
|
+
assert run.ctx.trigger == "message-send"
|
|
95
|
+
assert [m["id"] for m in run.ctx.messages] == ["s1"]
|
|
96
|
+
assert run.ctx.input_outcomes == ((tool_call("r1"), None, None),)
|
|
97
|
+
await drv.wait_status("running")
|
|
98
|
+
assert "inputRequests" not in drv.run
|
|
99
|
+
run.ack()
|
|
100
|
+
run.finish(RunManager.Complete())
|
|
101
|
+
await drv.wait_status("ready")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_malformed_restore_states_raise():
|
|
105
|
+
for state, match in [
|
|
106
|
+
({"runs": "nope"}, "exactly one entry"),
|
|
107
|
+
({"runs": [parked_entry(), parked_entry()]}, "exactly one entry"),
|
|
108
|
+
({"runs": ["nope"]}, "must be an object"),
|
|
109
|
+
({"runs": [parked_entry(status="running")]}, "input-required"),
|
|
110
|
+
({"runs": [parked_entry(runId="")]}, "runId"),
|
|
111
|
+
({"runs": [parked_entry(runId=None)]}, "runId"),
|
|
112
|
+
({"runs": [parked_entry(queue=None)]}, "queue"),
|
|
113
|
+
({"runs": [parked_entry(steerQueue=None)]}, "steerQueue"),
|
|
114
|
+
({"runs": [parked_entry(dispatching={"trigger": "steer"})]}, "dispatching"),
|
|
115
|
+
({"runs": [parked_entry(error={"message": "x"})]}, "error"),
|
|
116
|
+
({"runs": [parked_entry(inputRequests=[])]}, "non-empty list"),
|
|
117
|
+
({"runs": [parked_entry(inputRequests=["nope"])]}, "objects"),
|
|
118
|
+
(
|
|
119
|
+
{
|
|
120
|
+
"runs": [
|
|
121
|
+
parked_entry(
|
|
122
|
+
inputRequests=[
|
|
123
|
+
{**tool_call("r1"), "response": {"output": "x"}}
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
"response",
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
{"runs": [parked_entry(inputRequests=[{"type": "tool-call"}])]},
|
|
132
|
+
"non-empty string",
|
|
133
|
+
),
|
|
134
|
+
]:
|
|
135
|
+
with pytest.raises(ValueError, match=match):
|
|
136
|
+
construct(state)
|
|
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
|