harness-sdk-python 0.29.0__tar.gz → 0.29.2__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.29.0 → harness_sdk_python-0.29.2}/PKG-INFO +1 -1
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/pyproject.toml +1 -1
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/deepagents.py +6 -3
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/voice.py +8 -2
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_deepagents_messages.py +14 -2
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_voice.py +52 -7
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/voice_helpers.py +5 -1
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/.gitignore +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/README.md +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/examples/__init__.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.29.0 → harness_sdk_python-0.29.2}/tests/test_stopping_window.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""DeepagentsMessages: the ``messages`` slice of a Statewire host, projected
|
|
2
2
|
from a langgraph checkpointer in the deepagents dialect. The checkpointer is
|
|
3
3
|
the source of truth and the wire state is its projection, restamped with
|
|
4
|
-
``branch: {id, siblings}`` metadata after every ingest
|
|
4
|
+
``branch: {id, siblings}`` metadata after every ingest; ephemeral overlay
|
|
5
|
+
entries carry ``ephemeral: true`` instead. A rewind restores the
|
|
5
6
|
projection from the target message's anchor checkpoint; a branch switch clones
|
|
6
7
|
the target branch's tip as the checkpointer's latest checkpoint, so resumption
|
|
7
8
|
and reboot both land on the switched world.
|
|
@@ -243,7 +244,7 @@ class DeepagentsMessages:
|
|
|
243
244
|
return snapshot
|
|
244
245
|
|
|
245
246
|
def upsert_ephemeral_message(self, message: dict[str, Any]) -> None:
|
|
246
|
-
"""Render an in-flight ``{id, role, text}`` entry in ``state["messages"]``; an ``append_messages`` of the same id replaces it durably."""
|
|
247
|
+
"""Render an in-flight ``{id, role, text}`` entry in ``state["messages"]`` tagged ``ephemeral: true``; an ``append_messages`` of the same id replaces it durably."""
|
|
247
248
|
if message["role"] not in ("user", "assistant"):
|
|
248
249
|
raise ValueError(f"unsupported message role: {message['role']!r}")
|
|
249
250
|
if message["id"] not in self._ephemeral and any(
|
|
@@ -280,6 +281,8 @@ class DeepagentsMessages:
|
|
|
280
281
|
stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
|
|
281
282
|
durable = {message["id"] for message in stamped}
|
|
282
283
|
stamped.extend(
|
|
283
|
-
entry
|
|
284
|
+
{**entry, "ephemeral": True}
|
|
285
|
+
for id_, entry in self._ephemeral.items()
|
|
286
|
+
if id_ not in durable
|
|
284
287
|
)
|
|
285
288
|
self._replace(stamped)
|
|
@@ -13,11 +13,13 @@ status}`` — the SDP answer rides only the command's result payload
|
|
|
13
13
|
``{answer}``. The session is bound to the issuing client's connection: ``voice/end`` from the owner, a
|
|
14
14
|
provider ``closed`` event, or a connection drop ends it. Provider turn
|
|
15
15
|
events render live in ``state["messages"]`` through
|
|
16
|
-
``Host.upsert_ephemeral_message`` (message id = turn id)
|
|
16
|
+
``Host.upsert_ephemeral_message`` (message id = turn id), tagged
|
|
17
|
+
``ephemeral: true`` on the wire; each completed
|
|
17
18
|
turn commits through ``Host.commit_voice_turns`` (serialized, in
|
|
18
19
|
done-order) and the durable entry replaces the ephemeral one under the
|
|
19
20
|
same id, a failed commit keeps its turns for the next flush; session end
|
|
20
|
-
flushes the remaining done turns
|
|
21
|
+
flushes the remaining done turns, then commits in-progress turns with
|
|
22
|
+
their partial text; in-progress turns with no text yet are dropped.
|
|
21
23
|
|
|
22
24
|
Rejections: ``voice-active`` (a session is already live), ``no-voice``
|
|
23
25
|
(no voice manager declared, or ``voice/end`` with no session),
|
|
@@ -188,6 +190,10 @@ class VoiceManager(Tray):
|
|
|
188
190
|
await provider.close()
|
|
189
191
|
except Exception:
|
|
190
192
|
logger.exception("voice provider close failed for session %r", self.id)
|
|
193
|
+
self._turns.sort(key=lambda turn: not turn["done"])
|
|
194
|
+
for turn in self._turns:
|
|
195
|
+
if turn["text"]:
|
|
196
|
+
turn["done"] = True
|
|
191
197
|
await self._flush()
|
|
192
198
|
turns, self._turns = self._turns, []
|
|
193
199
|
for turn in turns:
|
|
@@ -170,10 +170,16 @@ async def test_ingest_turn_refetch_discards_the_capture():
|
|
|
170
170
|
async def test_ephemeral_message_renders_and_updates_in_place():
|
|
171
171
|
_, _, _, messages, state = await seeded()
|
|
172
172
|
messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "hel"})
|
|
173
|
-
assert state["messages"][-1] == {
|
|
173
|
+
assert state["messages"][-1] == {
|
|
174
|
+
"id": "v1",
|
|
175
|
+
"type": "human",
|
|
176
|
+
"content": "hel",
|
|
177
|
+
"ephemeral": True,
|
|
178
|
+
}
|
|
174
179
|
messages.upsert_ephemeral_message({"id": "v1", "role": "user", "text": "hello"})
|
|
175
180
|
assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
|
|
176
181
|
assert state["messages"][-1]["content"] == "hello"
|
|
182
|
+
assert all("ephemeral" not in m for m in state["messages"][:-1])
|
|
177
183
|
|
|
178
184
|
|
|
179
185
|
async def test_ephemeral_message_survives_a_turn_ingest():
|
|
@@ -181,7 +187,12 @@ async def test_ephemeral_message_survives_a_turn_ingest():
|
|
|
181
187
|
messages.upsert_ephemeral_message({"id": "v1", "role": "assistant", "text": "hi"})
|
|
182
188
|
await turn(graph, messages, Ctx("message-send", (user("u3", "three"),)))
|
|
183
189
|
assert ids(state) == ["u1", "a0", "u2", "a1", "u3", "a2", "v1"]
|
|
184
|
-
assert state["messages"][-1] == {
|
|
190
|
+
assert state["messages"][-1] == {
|
|
191
|
+
"id": "v1",
|
|
192
|
+
"type": "ai",
|
|
193
|
+
"content": "hi",
|
|
194
|
+
"ephemeral": True,
|
|
195
|
+
}
|
|
185
196
|
|
|
186
197
|
|
|
187
198
|
async def test_append_messages_replaces_the_ephemeral_entry_durably():
|
|
@@ -190,6 +201,7 @@ async def test_append_messages_replaces_the_ephemeral_entry_durably():
|
|
|
190
201
|
await messages.append_messages([{"id": "v1", "role": "user", "text": "spoken"}])
|
|
191
202
|
assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
|
|
192
203
|
assert "branch" in state["messages"][-1]
|
|
204
|
+
assert "ephemeral" not in state["messages"][-1]
|
|
193
205
|
messages.drop_ephemeral_message("v1")
|
|
194
206
|
assert ids(state) == ["u1", "a0", "u2", "a1", "v1"]
|
|
195
207
|
|
|
@@ -88,7 +88,14 @@ async def test_turn_streams_into_messages_ephemerally():
|
|
|
88
88
|
events.turn_delta("t1", "there")
|
|
89
89
|
await drv.wait(
|
|
90
90
|
lambda s: messages_of(s)
|
|
91
|
-
== [
|
|
91
|
+
== [
|
|
92
|
+
{
|
|
93
|
+
"id": "t1",
|
|
94
|
+
"type": "human",
|
|
95
|
+
"content": "hello there",
|
|
96
|
+
"ephemeral": True,
|
|
97
|
+
}
|
|
98
|
+
]
|
|
92
99
|
)
|
|
93
100
|
assert bench.commits == []
|
|
94
101
|
|
|
@@ -141,7 +148,14 @@ async def test_commit_failure_keeps_the_turn_until_the_next_flush():
|
|
|
141
148
|
assert bench.commits == []
|
|
142
149
|
await drv.wait(
|
|
143
150
|
lambda s: messages_of(s)
|
|
144
|
-
== [
|
|
151
|
+
== [
|
|
152
|
+
{
|
|
153
|
+
"id": "t1",
|
|
154
|
+
"type": "human",
|
|
155
|
+
"content": "question",
|
|
156
|
+
"ephemeral": True,
|
|
157
|
+
}
|
|
158
|
+
]
|
|
145
159
|
)
|
|
146
160
|
# session end retries the kept turn
|
|
147
161
|
await drv.command("voice/end")
|
|
@@ -172,7 +186,7 @@ async def test_commit_failure_retries_in_order_with_the_next_turn():
|
|
|
172
186
|
]
|
|
173
187
|
|
|
174
188
|
|
|
175
|
-
async def
|
|
189
|
+
async def test_end_commits_in_progress_turns_with_partial_text():
|
|
176
190
|
bench = Bench()
|
|
177
191
|
async with voice_host(bench) as (drv, _app):
|
|
178
192
|
await drv.command("voice/start", {"offer": OFFER})
|
|
@@ -185,15 +199,41 @@ async def test_end_drops_in_progress_turns():
|
|
|
185
199
|
rsp = await drv.command("voice/end")
|
|
186
200
|
assert rsp["type"] == "accepted", rsp
|
|
187
201
|
assert bench.provider.closed
|
|
188
|
-
assert bench.commits == [
|
|
202
|
+
assert bench.commits == [
|
|
203
|
+
[{"id": "t1", "role": "user", "text": "question"}],
|
|
204
|
+
[{"id": "t2", "role": "assistant", "text": "half an ans"}],
|
|
205
|
+
]
|
|
206
|
+
# the durable entries replaced the ephemeral ones under the same ids
|
|
189
207
|
await drv.wait(
|
|
190
208
|
lambda s: voice_of(s) is None
|
|
191
209
|
and messages_of(s)
|
|
192
|
-
== [
|
|
210
|
+
== [
|
|
211
|
+
{"id": "t1", "type": "human", "content": "question"},
|
|
212
|
+
{"id": "t2", "type": "ai", "content": "half an ans"},
|
|
213
|
+
]
|
|
193
214
|
)
|
|
194
215
|
|
|
195
216
|
|
|
196
|
-
async def
|
|
217
|
+
async def test_end_commits_partial_turns_after_kept_done_turns():
|
|
218
|
+
bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
|
|
219
|
+
async with voice_host(bench) as (drv, _app):
|
|
220
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
221
|
+
events = bench.events()
|
|
222
|
+
events.turn_started("t1", "user")
|
|
223
|
+
events.turn_done("t1", "question")
|
|
224
|
+
await until(lambda: bench.commit_failures == [])
|
|
225
|
+
events.turn_started("t2", "assistant")
|
|
226
|
+
events.turn_delta("t2", "half an ans")
|
|
227
|
+
await drv.command("voice/end")
|
|
228
|
+
assert bench.commits == [
|
|
229
|
+
[
|
|
230
|
+
{"id": "t1", "role": "user", "text": "question"},
|
|
231
|
+
{"id": "t2", "role": "assistant", "text": "half an ans"},
|
|
232
|
+
]
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def test_end_drops_started_turns_with_no_text():
|
|
197
237
|
bench = Bench()
|
|
198
238
|
async with voice_host(bench) as (drv, _app):
|
|
199
239
|
await drv.command("voice/start", {"offer": OFFER})
|
|
@@ -220,9 +260,14 @@ async def test_provider_closed_event_ends_and_commits():
|
|
|
220
260
|
events = bench.events()
|
|
221
261
|
events.turn_started("t1", "assistant")
|
|
222
262
|
events.turn_done("t1", "goodbye")
|
|
263
|
+
events.turn_started("t2", "user")
|
|
264
|
+
events.turn_delta("t2", "wai")
|
|
223
265
|
events.closed("server")
|
|
224
266
|
await drv.wait(lambda s: voice_of(s) is None)
|
|
225
|
-
assert bench.commits == [
|
|
267
|
+
assert bench.commits == [
|
|
268
|
+
[{"id": "t1", "role": "assistant", "text": "goodbye"}],
|
|
269
|
+
[{"id": "t2", "role": "user", "text": "wai"}],
|
|
270
|
+
]
|
|
226
271
|
assert_rejected(await drv.command("voice/end"), "no-voice")
|
|
227
272
|
|
|
228
273
|
|
|
@@ -83,7 +83,11 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
|
83
83
|
durable_ids = {m["id"] for m in self._durable}
|
|
84
84
|
self.state["messages"] = [
|
|
85
85
|
*self._durable,
|
|
86
|
-
*(
|
|
86
|
+
*(
|
|
87
|
+
{**e, "ephemeral": True}
|
|
88
|
+
for i, e in self._ephemeral.items()
|
|
89
|
+
if i not in durable_ids
|
|
90
|
+
),
|
|
87
91
|
]
|
|
88
92
|
|
|
89
93
|
async def get_voice_seed(self):
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|