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.
Files changed (44) hide show
  1. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/PKG-INFO +3 -3
  2. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/__init__.py +2 -0
  4. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/voice.py +43 -14
  5. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_voice.py +67 -11
  6. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/voice_helpers.py +4 -0
  7. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/.gitignore +0 -0
  8. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/README.md +0 -0
  9. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/examples/__init__.py +0 -0
  10. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/examples/runs_app.py +0 -0
  11. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/checkpoints.py +0 -0
  12. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/deepagents.py +0 -0
  13. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/src/harness_sdk/run_manager.py +0 -0
  16. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/run_helpers.py +0 -0
  17. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_admit_steer.py +0 -0
  18. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_applied_visibility.py +0 -0
  19. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_batches.py +0 -0
  20. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_branch_anchor.py +0 -0
  21. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_checkpoints.py +0 -0
  22. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_deepagents_messages.py +0 -0
  23. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_dispatching.py +0 -0
  24. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_durability.py +0 -0
  25. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_edit_dispatched.py +0 -0
  26. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_edit_reload.py +0 -0
  27. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_enqueue.py +0 -0
  28. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_input_required.py +0 -0
  30. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_settle.py +0 -0
  40. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_steer.py +0 -0
  41. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_stop_continue.py +0 -0
  42. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_stopping_window.py +0 -0
  43. {harness_sdk_python-0.31.0 → harness_sdk_python-0.31.2}/tests/test_voice_delegation.py +0 -0
  44. {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.0
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.0
9
- Requires-Dist: statewire<0.13,>=0.12.0
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.0"
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.0,<0.13", "pinned>=0.14.0,<0.15"]
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 a connection drop ends it. Provider turn
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), ``no-voice``
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
- connection_poll = 0.5
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._watcher: "asyncio.Task[Any] | None" = None
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
- async def _watch(self, caller: Any) -> None:
268
- while caller.is_connected:
269
- await asyncio.sleep(self.connection_poll)
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
- watcher, self._watcher = self._watcher, None
283
- if watcher is not None and watcher is not asyncio.current_task():
284
- watcher.cancel()
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 or self.live() is not None:
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 test_connection_drop_ends_session(monkeypatch):
280
- monkeypatch.setattr(VoiceManager, "connection_poll", 0.02)
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.next_event()
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