harness-sdk-python 0.31.0__tar.gz → 0.31.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.31.0 → harness_sdk_python-0.31.1}/PKG-INFO +2 -2
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/pyproject.toml +2 -2
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/__init__.py +2 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/voice.py +40 -12
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_voice.py +54 -10
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/voice_helpers.py +4 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/.gitignore +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/README.md +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/examples/__init__.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.1}/tests/test_voice_frontend_tools.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.31.
|
|
3
|
+
Version: 0.31.1
|
|
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
8
|
Requires-Dist: pinned<0.15,>=0.14.0
|
|
9
|
-
Requires-Dist: statewire<0.13,>=0.12.
|
|
9
|
+
Requires-Dist: statewire<0.13,>=0.12.3
|
|
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,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.31.
|
|
3
|
+
version = "0.31.1"
|
|
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.12.
|
|
8
|
+
dependencies = ["statewire>=0.12.3,<0.13", "pinned>=0.14.0,<0.15"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -21,6 +21,7 @@ from .voice import (
|
|
|
21
21
|
end_voice,
|
|
22
22
|
start_voice,
|
|
23
23
|
temp_frontend_tool_result,
|
|
24
|
+
voice_client_departed,
|
|
24
25
|
voice_manager,
|
|
25
26
|
)
|
|
26
27
|
|
|
@@ -47,5 +48,6 @@ __all__ = [
|
|
|
47
48
|
"run_manager",
|
|
48
49
|
"start_voice",
|
|
49
50
|
"temp_frontend_tool_result",
|
|
51
|
+
"voice_client_departed",
|
|
50
52
|
"voice_manager",
|
|
51
53
|
]
|
|
@@ -16,7 +16,11 @@ projects into ``toolCalls`` as ``{callId, name, arguments}``; the owning
|
|
|
16
16
|
client answers it with ``voice/temp-frontend-tool-result {call_id,
|
|
17
17
|
output}``, which forwards through ``provider.return_tool_result`` and
|
|
18
18
|
clears the entry. The session is bound to the issuing client's connection: ``voice/end`` from the owner, a
|
|
19
|
-
provider ``closed`` event, or
|
|
19
|
+
provider ``closed`` event, or the owner staying detached past the
|
|
20
|
+
``departure_grace`` window (the host's ``client_departed`` hook starts the
|
|
21
|
+
timer, the re-check runs at expiry) ends it; ``voice/start`` while the live
|
|
22
|
+
session's owner is detached ends the orphan and starts the new session.
|
|
23
|
+
Provider turn
|
|
20
24
|
events render live in ``state["messages"]`` through
|
|
21
25
|
``Host.upsert_ephemeral_message`` (message id = turn id), tagged
|
|
22
26
|
``ephemeral: true`` on the wire; each completed
|
|
@@ -40,7 +44,8 @@ same ``toolCalls`` projection: the client's ``voice/temp-frontend-tool-result``
|
|
|
40
44
|
resolves the await instead of forwarding to the provider, and session end
|
|
41
45
|
fails outstanding awaits.
|
|
42
46
|
|
|
43
|
-
Rejections: ``voice-active`` (a session is already live
|
|
47
|
+
Rejections: ``voice-active`` (a session is already live and its owner
|
|
48
|
+
attached), ``no-voice``
|
|
44
49
|
(no voice manager declared, or ``voice/end`` with no session),
|
|
45
50
|
``not-voice-owner`` (``voice/end`` from another client),
|
|
46
51
|
``voice-unavailable`` (the factory or ``provider.start`` raised).
|
|
@@ -111,7 +116,7 @@ class VoiceManager(Tray):
|
|
|
111
116
|
buffer rendered live in ``state["messages"]``; completed turns commit
|
|
112
117
|
per turn and ended sessions flush and self-delete."""
|
|
113
118
|
|
|
114
|
-
|
|
119
|
+
departure_grace = 10.0
|
|
115
120
|
|
|
116
121
|
clientId = observable(None)
|
|
117
122
|
status = observable("connecting")
|
|
@@ -133,7 +138,7 @@ class VoiceManager(Tray):
|
|
|
133
138
|
self._turns: list[dict[str, Any]] = []
|
|
134
139
|
self._flushing = asyncio.Lock()
|
|
135
140
|
self._ended = False
|
|
136
|
-
self.
|
|
141
|
+
self._grace: "asyncio.Task[Any] | None" = None
|
|
137
142
|
self._handoff_pending: list[dict[str, str]] = []
|
|
138
143
|
self._seen_requests: set[str] = set()
|
|
139
144
|
self._delegating = asyncio.Lock()
|
|
@@ -261,12 +266,18 @@ class VoiceManager(Tray):
|
|
|
261
266
|
self._provider = provider
|
|
262
267
|
self.status.value = "live"
|
|
263
268
|
applied()
|
|
264
|
-
self._watcher = self._spawn(self._watch(caller))
|
|
265
269
|
return {"answer": answer}
|
|
266
270
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
271
|
+
def client_departed(self) -> None:
|
|
272
|
+
if self._ended or self._grace is not None:
|
|
273
|
+
return
|
|
274
|
+
self._grace = self._spawn(self._departure_expired())
|
|
275
|
+
|
|
276
|
+
async def _departure_expired(self) -> None:
|
|
277
|
+
await asyncio.sleep(self.departure_grace)
|
|
278
|
+
self._grace = None
|
|
279
|
+
if self._host.has_subscriber(self.clientId.value):
|
|
280
|
+
return
|
|
270
281
|
await self._background_end("connection-drop")
|
|
271
282
|
|
|
272
283
|
async def _background_end(self, reason: str) -> None:
|
|
@@ -279,9 +290,9 @@ class VoiceManager(Tray):
|
|
|
279
290
|
if self._ended:
|
|
280
291
|
return
|
|
281
292
|
self._ended = True
|
|
282
|
-
|
|
283
|
-
if
|
|
284
|
-
|
|
293
|
+
grace, self._grace = self._grace, None
|
|
294
|
+
if grace is not None and grace is not asyncio.current_task():
|
|
295
|
+
grace.cancel()
|
|
285
296
|
provider, self._provider = self._provider, None
|
|
286
297
|
if provider is not None:
|
|
287
298
|
try:
|
|
@@ -560,6 +571,11 @@ class VoiceManagerNamespace(TrayMapNamespace):
|
|
|
560
571
|
session = self._session
|
|
561
572
|
return session if session is not None and not session._ended else None
|
|
562
573
|
|
|
574
|
+
def client_departed(self, client_id: str) -> None:
|
|
575
|
+
session = self.live()
|
|
576
|
+
if session is not None and session.clientId.value == client_id:
|
|
577
|
+
session.client_departed()
|
|
578
|
+
|
|
563
579
|
def _mount(self, tray: VoiceManager) -> None:
|
|
564
580
|
if tray.storage._restored:
|
|
565
581
|
return # a crash's leftover row: the pump finishes its delete unmounted
|
|
@@ -578,8 +594,13 @@ class VoiceManagerNamespace(TrayMapNamespace):
|
|
|
578
594
|
if not isinstance(offer, str) or offer == "":
|
|
579
595
|
raise _reject("invalid-message", "offer must be a non-empty SDP string")
|
|
580
596
|
tools = _validated_tools(params.get("tools"))
|
|
581
|
-
if self._starting
|
|
597
|
+
if self._starting:
|
|
582
598
|
raise _reject("voice-active", "a voice session is already live")
|
|
599
|
+
session = self.live()
|
|
600
|
+
if session is not None:
|
|
601
|
+
if self._api.has_subscriber(session.clientId.value):
|
|
602
|
+
raise _reject("voice-active", "a voice session is already live")
|
|
603
|
+
await session._end("connection-drop")
|
|
583
604
|
self._starting = True
|
|
584
605
|
try:
|
|
585
606
|
seed = _capped_seed(await self._api.get_voice_seed())
|
|
@@ -675,6 +696,13 @@ async def end_voice(host: Any, *, ctx: Any) -> None:
|
|
|
675
696
|
return await namespace.end(ctx=ctx)
|
|
676
697
|
|
|
677
698
|
|
|
699
|
+
def voice_client_departed(host: Any, client_id: str) -> None:
|
|
700
|
+
"""The Statewire ``client_departed`` hook body: routes to the host's declared voice manager."""
|
|
701
|
+
namespace = _voice_of(host)
|
|
702
|
+
if namespace is not None:
|
|
703
|
+
namespace.client_departed(client_id)
|
|
704
|
+
|
|
705
|
+
|
|
678
706
|
async def temp_frontend_tool_result(host: Any, params: Any, *, ctx: Any) -> None:
|
|
679
707
|
"""The ``voice/temp-frontend-tool-result`` command body: routes to the host's declared voice manager."""
|
|
680
708
|
namespace = _voice_of(host)
|
|
@@ -276,19 +276,23 @@ async def test_provider_closed_event_ends_and_commits():
|
|
|
276
276
|
assert_rejected(await drv.command("voice/end"), "no-voice")
|
|
277
277
|
|
|
278
278
|
|
|
279
|
-
async def
|
|
280
|
-
|
|
279
|
+
async def start_from_second_client(drv, stream, *, client_id="c2"):
|
|
280
|
+
await stream.next_event()
|
|
281
|
+
response = await post_frame(
|
|
282
|
+
drv._client,
|
|
283
|
+
{"cmd": [_statement("voice/start", {"offer": OFFER}, 1)]},
|
|
284
|
+
client_id=client_id,
|
|
285
|
+
)
|
|
286
|
+
assert response.status_code == 200, response.text
|
|
287
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("clientId") == client_id)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def test_connection_drop_ends_session_after_grace(monkeypatch):
|
|
291
|
+
monkeypatch.setattr(VoiceManager, "departure_grace", 0.05)
|
|
281
292
|
bench = Bench()
|
|
282
293
|
async with voice_host(bench) as (drv, app):
|
|
283
294
|
async with stream_of(app, client_id="c2") as stream:
|
|
284
|
-
await stream
|
|
285
|
-
response = await post_frame(
|
|
286
|
-
drv._client,
|
|
287
|
-
{"cmd": [_statement("voice/start", {"offer": OFFER}, 1)]},
|
|
288
|
-
client_id="c2",
|
|
289
|
-
)
|
|
290
|
-
assert response.status_code == 200, response.text
|
|
291
|
-
await drv.wait(lambda s: (voice_of(s) or {}).get("clientId") == "c2")
|
|
295
|
+
await start_from_second_client(drv, stream)
|
|
292
296
|
events = bench.events()
|
|
293
297
|
events.turn_started("t1", "user")
|
|
294
298
|
events.turn_done("t1", "dropped mid-call")
|
|
@@ -299,6 +303,46 @@ async def test_connection_drop_ends_session(monkeypatch):
|
|
|
299
303
|
]
|
|
300
304
|
|
|
301
305
|
|
|
306
|
+
async def test_reattach_within_grace_keeps_the_session(monkeypatch):
|
|
307
|
+
monkeypatch.setattr(VoiceManager, "departure_grace", 0.2)
|
|
308
|
+
bench = Bench()
|
|
309
|
+
async with voice_host(bench) as (drv, app):
|
|
310
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
311
|
+
await start_from_second_client(drv, stream)
|
|
312
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
313
|
+
await stream.next_event()
|
|
314
|
+
await asyncio.sleep(0.4)
|
|
315
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
|
|
316
|
+
assert not bench.provider.closed
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def test_start_over_a_detached_owner_replaces_the_session():
|
|
320
|
+
bench = Bench()
|
|
321
|
+
async with voice_host(bench) as (drv, app):
|
|
322
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
323
|
+
await start_from_second_client(drv, stream)
|
|
324
|
+
orphaned = bench.provider
|
|
325
|
+
bench.provider = FakeVoiceProvider()
|
|
326
|
+
rsp = await drv.command("voice/start", {"offer": OFFER})
|
|
327
|
+
assert rsp["type"] == "accepted", rsp
|
|
328
|
+
assert orphaned.closed
|
|
329
|
+
await drv.wait(
|
|
330
|
+
lambda s: (voice_of(s) or {}).get("clientId") == drv.client_id
|
|
331
|
+
and (voice_of(s) or {}).get("status") == "live"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def test_start_over_an_attached_owner_still_rejects_voice_active():
|
|
336
|
+
bench = Bench()
|
|
337
|
+
async with voice_host(bench) as (drv, app):
|
|
338
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
339
|
+
await start_from_second_client(drv, stream)
|
|
340
|
+
assert_rejected(
|
|
341
|
+
await drv.command("voice/start", {"offer": OFFER}), "voice-active"
|
|
342
|
+
)
|
|
343
|
+
assert not bench.provider.closed
|
|
344
|
+
|
|
345
|
+
|
|
302
346
|
async def test_seed_takes_the_128_tail():
|
|
303
347
|
bench = Bench(transcript=[seed_message(i) for i in range(130)])
|
|
304
348
|
async with voice_host(bench) as (drv, _app):
|
|
@@ -16,6 +16,7 @@ from harness_sdk import (
|
|
|
16
16
|
end_voice,
|
|
17
17
|
start_voice,
|
|
18
18
|
temp_frontend_tool_result,
|
|
19
|
+
voice_client_departed,
|
|
19
20
|
voice_manager,
|
|
20
21
|
)
|
|
21
22
|
|
|
@@ -139,6 +140,9 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
|
139
140
|
async def voice_tool_result(self, params, *, ctx):
|
|
140
141
|
return await temp_frontend_tool_result(self, params, ctx=ctx)
|
|
141
142
|
|
|
143
|
+
def client_departed(self, client_id):
|
|
144
|
+
voice_client_departed(self, client_id)
|
|
145
|
+
|
|
142
146
|
if not declare:
|
|
143
147
|
return Host
|
|
144
148
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|