harness-sdk-python 0.31.0__tar.gz → 0.31.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.31.0 → harness_sdk_python-0.31.2}/PKG-INFO +3 -3
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/pyproject.toml +2 -2
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/__init__.py +2 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/voice.py +43 -14
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_voice.py +67 -11
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/voice_helpers.py +4 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/.gitignore +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/README.md +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/examples/__init__.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/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.2
|
|
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.15,>=0.14.
|
|
9
|
-
Requires-Dist: statewire<0.13,>=0.12.
|
|
8
|
+
Requires-Dist: pinned<0.15,>=0.14.6
|
|
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.2"
|
|
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.6,<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())
|
|
@@ -651,11 +672,11 @@ def voice_manager() -> Callable[[Callable[..., Any]], VoiceManagerDescriptor]:
|
|
|
651
672
|
return decorate
|
|
652
673
|
|
|
653
674
|
|
|
654
|
-
def _voice_of(host: Any) -> VoiceManagerNamespace | None:
|
|
675
|
+
def _voice_of(host: Any, *, create: bool = True) -> VoiceManagerNamespace | None:
|
|
655
676
|
for cls in type(host).__mro__:
|
|
656
677
|
for name, member in vars(cls).items():
|
|
657
678
|
if isinstance(member, VoiceManagerDescriptor):
|
|
658
|
-
return getattr(host, name)
|
|
679
|
+
return getattr(host, name) if create else member.peek(host)
|
|
659
680
|
return None
|
|
660
681
|
|
|
661
682
|
|
|
@@ -675,6 +696,14 @@ 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
|
+
# peek: instantiating here would stamp voice state after the attach snapshot
|
|
702
|
+
namespace = _voice_of(host, create=False)
|
|
703
|
+
if namespace is not None:
|
|
704
|
+
namespace.client_departed(client_id)
|
|
705
|
+
|
|
706
|
+
|
|
678
707
|
async def temp_frontend_tool_result(host: Any, params: Any, *, ctx: Any) -> None:
|
|
679
708
|
"""The ``voice/temp-frontend-tool-result`` command body: routes to the host's declared voice manager."""
|
|
680
709
|
namespace = _voice_of(host)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import asyncio
|
|
5
5
|
|
|
6
6
|
from run_helpers import _statement, assert_rejected
|
|
7
|
-
from statewire_helpers import post_frame, stream_of
|
|
7
|
+
from statewire_helpers import apply_ops, post_frame, stream_of
|
|
8
8
|
from voice_helpers import (
|
|
9
9
|
ANSWER,
|
|
10
10
|
OFFER,
|
|
@@ -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,58 @@ 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_departure_before_any_voice_command_leaves_state_untouched():
|
|
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 stream.next_event()
|
|
324
|
+
await asyncio.sleep(0.05)
|
|
325
|
+
assert "voice" not in drv.replica
|
|
326
|
+
async with stream_of(app, client_id="c3") as stream:
|
|
327
|
+
snapshot = apply_ops({}, await stream.next_event())
|
|
328
|
+
assert "voice" not in snapshot
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def test_start_over_a_detached_owner_replaces_the_session():
|
|
332
|
+
bench = Bench()
|
|
333
|
+
async with voice_host(bench) as (drv, app):
|
|
334
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
335
|
+
await start_from_second_client(drv, stream)
|
|
336
|
+
orphaned = bench.provider
|
|
337
|
+
bench.provider = FakeVoiceProvider()
|
|
338
|
+
rsp = await drv.command("voice/start", {"offer": OFFER})
|
|
339
|
+
assert rsp["type"] == "accepted", rsp
|
|
340
|
+
assert orphaned.closed
|
|
341
|
+
await drv.wait(
|
|
342
|
+
lambda s: (voice_of(s) or {}).get("clientId") == drv.client_id
|
|
343
|
+
and (voice_of(s) or {}).get("status") == "live"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def test_start_over_an_attached_owner_still_rejects_voice_active():
|
|
348
|
+
bench = Bench()
|
|
349
|
+
async with voice_host(bench) as (drv, app):
|
|
350
|
+
async with stream_of(app, client_id="c2") as stream:
|
|
351
|
+
await start_from_second_client(drv, stream)
|
|
352
|
+
assert_rejected(
|
|
353
|
+
await drv.command("voice/start", {"offer": OFFER}), "voice-active"
|
|
354
|
+
)
|
|
355
|
+
assert not bench.provider.closed
|
|
356
|
+
|
|
357
|
+
|
|
302
358
|
async def test_seed_takes_the_128_tail():
|
|
303
359
|
bench = Bench(transcript=[seed_message(i) for i in range(130)])
|
|
304
360
|
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
|