harness-sdk-python 0.30.1__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.30.1 → harness_sdk_python-0.31.1}/PKG-INFO +2 -2
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/pyproject.toml +2 -2
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/__init__.py +2 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/voice.py +106 -33
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_voice.py +54 -10
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_voice_frontend_tools.py +89 -2
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/voice_helpers.py +9 -1
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/.gitignore +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/README.md +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/examples/__init__.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.1}/tests/test_voice_delegation.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
|
@@ -34,9 +38,14 @@ turns since the previous task. Interim ``speak`` text and the returned
|
|
|
34
38
|
result flow back as speakable context appends; a named call's result
|
|
35
39
|
returns via ``provider.return_tool_result``; a failed handler surfaces to
|
|
36
40
|
the model as commentary (task) or an error output (call). The running unit
|
|
37
|
-
projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest.
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest. The
|
|
42
|
+
handler's ``call_frontend_tool`` invokes a client-declared tool through the
|
|
43
|
+
same ``toolCalls`` projection: the client's ``voice/temp-frontend-tool-result``
|
|
44
|
+
resolves the await instead of forwarding to the provider, and session end
|
|
45
|
+
fails outstanding awaits.
|
|
46
|
+
|
|
47
|
+
Rejections: ``voice-active`` (a session is already live and its owner
|
|
48
|
+
attached), ``no-voice``
|
|
40
49
|
(no voice manager declared, or ``voice/end`` with no session),
|
|
41
50
|
``not-voice-owner`` (``voice/end`` from another client),
|
|
42
51
|
``voice-unavailable`` (the factory or ``provider.start`` raised).
|
|
@@ -107,7 +116,7 @@ class VoiceManager(Tray):
|
|
|
107
116
|
buffer rendered live in ``state["messages"]``; completed turns commit
|
|
108
117
|
per turn and ended sessions flush and self-delete."""
|
|
109
118
|
|
|
110
|
-
|
|
119
|
+
departure_grace = 10.0
|
|
111
120
|
|
|
112
121
|
clientId = observable(None)
|
|
113
122
|
status = observable("connecting")
|
|
@@ -129,12 +138,13 @@ class VoiceManager(Tray):
|
|
|
129
138
|
self._turns: list[dict[str, Any]] = []
|
|
130
139
|
self._flushing = asyncio.Lock()
|
|
131
140
|
self._ended = False
|
|
132
|
-
self.
|
|
141
|
+
self._grace: "asyncio.Task[Any] | None" = None
|
|
133
142
|
self._handoff_pending: list[dict[str, str]] = []
|
|
134
143
|
self._seen_requests: set[str] = set()
|
|
135
144
|
self._delegating = asyncio.Lock()
|
|
136
145
|
self._tasks_run = 0
|
|
137
|
-
self._client_tools:
|
|
146
|
+
self._client_tools: "list[VoiceManager.ToolDef]" = []
|
|
147
|
+
self._executor_waits: dict[str, asyncio.Future[str]] = {}
|
|
138
148
|
|
|
139
149
|
def items(self):
|
|
140
150
|
yield from ()
|
|
@@ -227,8 +237,10 @@ class VoiceManager(Tray):
|
|
|
227
237
|
task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest",
|
|
228
238
|
*,
|
|
229
239
|
speak: Callable[[str], Awaitable[None]],
|
|
240
|
+
call_frontend_tool: Callable[[str, str], Awaitable[str]],
|
|
241
|
+
tools: "list[VoiceManager.ToolDef]",
|
|
230
242
|
) -> str:
|
|
231
|
-
"""Fulfill one delegated work unit; ``speak`` streams interim spoken updates and the return value is the result."""
|
|
243
|
+
"""Fulfill one delegated work unit; ``speak`` streams interim spoken updates, ``call_frontend_tool(name, arguments)`` runs a client-declared tool (``tools``) on the owning client, and the return value is the result."""
|
|
232
244
|
...
|
|
233
245
|
|
|
234
246
|
# ─── Session lifecycle ──────────────────────────────────
|
|
@@ -239,7 +251,7 @@ class VoiceManager(Tray):
|
|
|
239
251
|
applied: Callable[[], None],
|
|
240
252
|
) -> Any:
|
|
241
253
|
self.clientId.value = caller.client_id
|
|
242
|
-
self._client_tools =
|
|
254
|
+
self._client_tools = tools
|
|
243
255
|
try:
|
|
244
256
|
provider = await self.handler(
|
|
245
257
|
VoiceManager.SessionContext(client_id=caller.client_id)
|
|
@@ -254,12 +266,18 @@ class VoiceManager(Tray):
|
|
|
254
266
|
self._provider = provider
|
|
255
267
|
self.status.value = "live"
|
|
256
268
|
applied()
|
|
257
|
-
self._watcher = self._spawn(self._watch(caller))
|
|
258
269
|
return {"answer": answer}
|
|
259
270
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
263
281
|
await self._background_end("connection-drop")
|
|
264
282
|
|
|
265
283
|
async def _background_end(self, reason: str) -> None:
|
|
@@ -272,9 +290,9 @@ class VoiceManager(Tray):
|
|
|
272
290
|
if self._ended:
|
|
273
291
|
return
|
|
274
292
|
self._ended = True
|
|
275
|
-
|
|
276
|
-
if
|
|
277
|
-
|
|
293
|
+
grace, self._grace = self._grace, None
|
|
294
|
+
if grace is not None and grace is not asyncio.current_task():
|
|
295
|
+
grace.cancel()
|
|
278
296
|
provider, self._provider = self._provider, None
|
|
279
297
|
if provider is not None:
|
|
280
298
|
try:
|
|
@@ -289,6 +307,11 @@ class VoiceManager(Tray):
|
|
|
289
307
|
turns, self._turns = self._turns, []
|
|
290
308
|
for turn in turns:
|
|
291
309
|
self._host.drop_ephemeral_message(turn["id"])
|
|
310
|
+
waits, self._executor_waits = self._executor_waits, {}
|
|
311
|
+
for wait in waits.values():
|
|
312
|
+
if not wait.done():
|
|
313
|
+
wait.set_exception(RuntimeError("the voice session ended"))
|
|
314
|
+
wait.exception()
|
|
292
315
|
self.status.value = "closed"
|
|
293
316
|
self.delete()
|
|
294
317
|
|
|
@@ -387,7 +410,7 @@ class VoiceManager(Tray):
|
|
|
387
410
|
self._tasks_run += 1
|
|
388
411
|
self.activeTask.value = {"taskId": task_id, "text": text}
|
|
389
412
|
try:
|
|
390
|
-
answer = await self.
|
|
413
|
+
answer = await self._delegate(task)
|
|
391
414
|
if not isinstance(answer, str) or answer == "":
|
|
392
415
|
raise TypeError(
|
|
393
416
|
f"handle_voice_task must return the result text, got {answer!r}"
|
|
@@ -403,39 +426,72 @@ class VoiceManager(Tray):
|
|
|
403
426
|
self.activeTask.value = None
|
|
404
427
|
await self._append(answer, channel="speakable")
|
|
405
428
|
|
|
429
|
+
async def _delegate(
|
|
430
|
+
self, task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest"
|
|
431
|
+
) -> str:
|
|
432
|
+
return await self._host.handle_voice_task(
|
|
433
|
+
task,
|
|
434
|
+
speak=self._speak,
|
|
435
|
+
call_frontend_tool=self._call_frontend_tool,
|
|
436
|
+
tools=self._client_tools,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def _project_call(self, call_id: str, name: str, arguments: str) -> None:
|
|
440
|
+
self.toolCalls.value = [
|
|
441
|
+
*self.toolCalls.value,
|
|
442
|
+
{"callId": call_id, "name": name, "arguments": arguments},
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
def _clear_call(self, call_id: str) -> None:
|
|
446
|
+
if self._ended:
|
|
447
|
+
return
|
|
448
|
+
self.toolCalls.value = [
|
|
449
|
+
call for call in self.toolCalls.value if call["callId"] != call_id
|
|
450
|
+
]
|
|
451
|
+
|
|
406
452
|
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
|
|
407
453
|
if self._ended or not self._claim(request.call_id):
|
|
408
454
|
return
|
|
409
|
-
if request.name in self._client_tools:
|
|
410
|
-
self.
|
|
411
|
-
*self.toolCalls.value,
|
|
412
|
-
{
|
|
413
|
-
"callId": request.call_id,
|
|
414
|
-
"name": request.name,
|
|
415
|
-
"arguments": request.arguments,
|
|
416
|
-
},
|
|
417
|
-
]
|
|
455
|
+
if any(tool["name"] == request.name for tool in self._client_tools):
|
|
456
|
+
self._project_call(request.call_id, request.name, request.arguments)
|
|
418
457
|
return
|
|
419
458
|
self._spawn(self._run_tool_call(request))
|
|
420
459
|
|
|
460
|
+
async def _call_frontend_tool(self, name: str, arguments: str) -> str:
|
|
461
|
+
if all(tool["name"] != name for tool in self._client_tools):
|
|
462
|
+
raise ValueError(f"{name!r} is not a client-declared frontend tool")
|
|
463
|
+
if self._ended:
|
|
464
|
+
raise RuntimeError("the voice session ended")
|
|
465
|
+
call_id = uuid4().hex
|
|
466
|
+
wait: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
467
|
+
self._executor_waits[call_id] = wait
|
|
468
|
+
self._project_call(call_id, name, arguments)
|
|
469
|
+
try:
|
|
470
|
+
return await wait
|
|
471
|
+
finally:
|
|
472
|
+
self._executor_waits.pop(call_id, None)
|
|
473
|
+
self._clear_call(call_id)
|
|
474
|
+
|
|
421
475
|
async def _answer_client_call(self, call_id: str, output: str) -> None:
|
|
422
|
-
|
|
423
|
-
if not any(call["callId"] == call_id for call in pending):
|
|
476
|
+
if not any(call["callId"] == call_id for call in self.toolCalls.value):
|
|
424
477
|
raise _reject(
|
|
425
478
|
"unknown-tool-call", f"no pending frontend tool call {call_id!r}"
|
|
426
479
|
)
|
|
480
|
+
wait = self._executor_waits.get(call_id)
|
|
481
|
+
if wait is not None:
|
|
482
|
+
if not wait.done():
|
|
483
|
+
wait.set_result(output)
|
|
484
|
+
return
|
|
427
485
|
provider = self._provider
|
|
428
486
|
if provider is None:
|
|
429
487
|
raise _reject("no-voice", "the voice session is not live")
|
|
430
488
|
await provider.return_tool_result(call_id, output)
|
|
431
|
-
self.
|
|
432
|
-
call for call in pending if call["callId"] != call_id
|
|
433
|
-
]
|
|
489
|
+
self._clear_call(call_id)
|
|
434
490
|
|
|
435
491
|
async def _run_tool_call(self, request: "VoiceManager.ToolRequest") -> None:
|
|
436
492
|
self.activeTask.value = {"taskId": request.call_id, "text": request.name}
|
|
437
493
|
try:
|
|
438
|
-
output = await self.
|
|
494
|
+
output = await self._delegate(request)
|
|
439
495
|
if not isinstance(output, str):
|
|
440
496
|
raise TypeError(
|
|
441
497
|
f"handle_voice_task must return the result text, got {output!r}"
|
|
@@ -515,6 +571,11 @@ class VoiceManagerNamespace(TrayMapNamespace):
|
|
|
515
571
|
session = self._session
|
|
516
572
|
return session if session is not None and not session._ended else None
|
|
517
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
|
+
|
|
518
579
|
def _mount(self, tray: VoiceManager) -> None:
|
|
519
580
|
if tray.storage._restored:
|
|
520
581
|
return # a crash's leftover row: the pump finishes its delete unmounted
|
|
@@ -533,8 +594,13 @@ class VoiceManagerNamespace(TrayMapNamespace):
|
|
|
533
594
|
if not isinstance(offer, str) or offer == "":
|
|
534
595
|
raise _reject("invalid-message", "offer must be a non-empty SDP string")
|
|
535
596
|
tools = _validated_tools(params.get("tools"))
|
|
536
|
-
if self._starting
|
|
597
|
+
if self._starting:
|
|
537
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")
|
|
538
604
|
self._starting = True
|
|
539
605
|
try:
|
|
540
606
|
seed = _capped_seed(await self._api.get_voice_seed())
|
|
@@ -630,6 +696,13 @@ async def end_voice(host: Any, *, ctx: Any) -> None:
|
|
|
630
696
|
return await namespace.end(ctx=ctx)
|
|
631
697
|
|
|
632
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
|
+
|
|
633
706
|
async def temp_frontend_tool_result(host: Any, params: Any, *, ctx: Any) -> None:
|
|
634
707
|
"""The ``voice/temp-frontend-tool-result`` command body: routes to the host's declared voice manager."""
|
|
635
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):
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""Frontend function tools contract: ``voice/start {tools}`` reaches the
|
|
2
|
-
provider, matching calls project into ``state["voice"]["toolCalls"]``,
|
|
3
|
-
``voice/temp-frontend-tool-result`` answers them
|
|
2
|
+
provider, matching calls project into ``state["voice"]["toolCalls"]``,
|
|
3
|
+
``voice/temp-frontend-tool-result`` answers them, and the delegated-task
|
|
4
|
+
handler's ``call_frontend_tool`` rides the same projection."""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
4
7
|
|
|
5
8
|
from run_helpers import assert_rejected
|
|
6
9
|
from voice_helpers import (
|
|
@@ -13,6 +16,13 @@ from voice_helpers import (
|
|
|
13
16
|
|
|
14
17
|
from harness_sdk import VoiceManager
|
|
15
18
|
|
|
19
|
+
|
|
20
|
+
async def until(predicate, timeout: float = 5) -> None:
|
|
21
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
22
|
+
while not predicate():
|
|
23
|
+
assert asyncio.get_running_loop().time() < deadline, "condition never held"
|
|
24
|
+
await asyncio.sleep(0.01)
|
|
25
|
+
|
|
16
26
|
TOOL = {
|
|
17
27
|
"name": "get_local_time",
|
|
18
28
|
"description": "Read the user's local date and time.",
|
|
@@ -93,6 +103,83 @@ async def test_unknown_call_id_rejects_unknown_tool_call():
|
|
|
93
103
|
)
|
|
94
104
|
|
|
95
105
|
|
|
106
|
+
async def test_executor_call_projects_and_the_result_resolves_the_await():
|
|
107
|
+
bench = Bench()
|
|
108
|
+
outputs: list[str] = []
|
|
109
|
+
|
|
110
|
+
async def handler(task, speak):
|
|
111
|
+
outputs.append(await bench.call_frontend_tool("get_local_time", "{}"))
|
|
112
|
+
return "wrapped it up"
|
|
113
|
+
|
|
114
|
+
bench.task_handler = handler
|
|
115
|
+
async with voice_host(bench) as (drv, _app):
|
|
116
|
+
await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
|
|
117
|
+
bench.events().task_requested("d1", "what time is it locally?")
|
|
118
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
119
|
+
[call] = drv.replica["voice"]["toolCalls"]
|
|
120
|
+
assert call["name"] == "get_local_time"
|
|
121
|
+
assert call["arguments"] == "{}"
|
|
122
|
+
assert bench.tool_defs == [TOOL]
|
|
123
|
+
rsp = await drv.command(
|
|
124
|
+
"voice/temp-frontend-tool-result",
|
|
125
|
+
{"call_id": call["callId"], "output": "12:30"},
|
|
126
|
+
)
|
|
127
|
+
assert rsp["type"] == "accepted", rsp
|
|
128
|
+
await until(
|
|
129
|
+
lambda: ("append_context", "wrapped it up", "speakable")
|
|
130
|
+
in bench.provider.calls
|
|
131
|
+
)
|
|
132
|
+
assert outputs == ["12:30"]
|
|
133
|
+
assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
|
|
134
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") == [])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def test_executor_call_with_undeclared_tool_raises_in_the_handler():
|
|
138
|
+
bench = Bench()
|
|
139
|
+
|
|
140
|
+
async def handler(task, speak):
|
|
141
|
+
return await bench.call_frontend_tool("open_pod_bay_doors", "{}")
|
|
142
|
+
|
|
143
|
+
bench.task_handler = handler
|
|
144
|
+
async with voice_host(bench) as (drv, _app):
|
|
145
|
+
await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
|
|
146
|
+
bench.events().task_requested("d1", "open up")
|
|
147
|
+
await until(
|
|
148
|
+
lambda: any(
|
|
149
|
+
c[0] == "append_context" and c[2] == "commentary"
|
|
150
|
+
for c in bench.provider.calls
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
[(_, text, _channel)] = [
|
|
154
|
+
c for c in bench.provider.calls if c[0] == "append_context"
|
|
155
|
+
]
|
|
156
|
+
assert "'open_pod_bay_doors' is not a client-declared frontend tool" in text
|
|
157
|
+
assert drv.replica["voice"]["toolCalls"] == []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_session_end_fails_a_pending_executor_call():
|
|
161
|
+
bench = Bench()
|
|
162
|
+
failures: list[Exception] = []
|
|
163
|
+
|
|
164
|
+
async def handler(task, speak):
|
|
165
|
+
try:
|
|
166
|
+
return await bench.call_frontend_tool("get_local_time", "{}")
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
failures.append(exc)
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
bench.task_handler = handler
|
|
172
|
+
async with voice_host(bench) as (drv, _app):
|
|
173
|
+
await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
|
|
174
|
+
bench.events().task_requested("d1", "what time is it?")
|
|
175
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
176
|
+
rsp = await drv.command("voice/end")
|
|
177
|
+
assert rsp["type"] == "accepted", rsp
|
|
178
|
+
await until(lambda: failures != [])
|
|
179
|
+
assert isinstance(failures[0], RuntimeError)
|
|
180
|
+
assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
|
|
181
|
+
|
|
182
|
+
|
|
96
183
|
async def test_tool_result_from_other_client_rejects_not_voice_owner():
|
|
97
184
|
bench = Bench()
|
|
98
185
|
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
|
|
|
@@ -72,6 +73,8 @@ class Bench:
|
|
|
72
73
|
contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
|
|
73
74
|
tasks: list[Any] = field(default_factory=list)
|
|
74
75
|
task_handler: Any = None
|
|
76
|
+
call_frontend_tool: Any = None
|
|
77
|
+
tool_defs: list[Any] = field(default_factory=list)
|
|
75
78
|
|
|
76
79
|
def events(self) -> VoiceManager.Events:
|
|
77
80
|
assert self.provider.events is not None, "provider.start never ran"
|
|
@@ -116,8 +119,10 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
|
116
119
|
del self._ephemeral[message_id]
|
|
117
120
|
self._project_messages()
|
|
118
121
|
|
|
119
|
-
async def handle_voice_task(self, task, *, speak):
|
|
122
|
+
async def handle_voice_task(self, task, *, speak, call_frontend_tool, tools):
|
|
120
123
|
bench.tasks.append(task)
|
|
124
|
+
bench.call_frontend_tool = call_frontend_tool
|
|
125
|
+
bench.tool_defs = tools
|
|
121
126
|
if bench.task_handler is not None:
|
|
122
127
|
return await bench.task_handler(task, speak)
|
|
123
128
|
request_id = getattr(task, "task_id", None) or task.call_id
|
|
@@ -135,6 +140,9 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
|
135
140
|
async def voice_tool_result(self, params, *, ctx):
|
|
136
141
|
return await temp_frontend_tool_result(self, params, ctx=ctx)
|
|
137
142
|
|
|
143
|
+
def client_departed(self, client_id):
|
|
144
|
+
voice_client_departed(self, client_id)
|
|
145
|
+
|
|
138
146
|
if not declare:
|
|
139
147
|
return Host
|
|
140
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
|