harness-sdk-python 0.30.0__tar.gz → 0.31.0__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.0 → harness_sdk_python-0.31.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/voice.py +75 -21
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_voice.py +6 -1
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_voice_delegation.py +63 -3
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_voice_frontend_tools.py +89 -2
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/voice_helpers.py +5 -1
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/.gitignore +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/README.md +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_stopping_window.py +0 -0
|
@@ -10,7 +10,7 @@ and the turn commit both stay host-owned.
|
|
|
10
10
|
``voice/start {offer, tools?}`` seeds the provider from the thread
|
|
11
11
|
transcript (at most 128 messages, tail taken), hands the client's frontend
|
|
12
12
|
function tool definitions to the provider, and projects ``state["voice"] =
|
|
13
|
-
{clientId, status, toolCalls}`` — the SDP answer rides only the command's
|
|
13
|
+
{clientId, status, toolCalls, activeTask}`` — the SDP answer rides only the command's
|
|
14
14
|
result payload ``{answer}``. A named call matching a client-declared tool
|
|
15
15
|
projects into ``toolCalls`` as ``{callId, name, arguments}``; the owning
|
|
16
16
|
client answers it with ``voice/temp-frontend-tool-result {call_id,
|
|
@@ -33,7 +33,12 @@ transcript handoff — the first task the conversation so far, follow-ups the
|
|
|
33
33
|
turns since the previous task. Interim ``speak`` text and the returned
|
|
34
34
|
result flow back as speakable context appends; a named call's result
|
|
35
35
|
returns via ``provider.return_tool_result``; a failed handler surfaces to
|
|
36
|
-
the model as commentary (task) or an error output (call).
|
|
36
|
+
the model as commentary (task) or an error output (call). The running unit
|
|
37
|
+
projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest. The
|
|
38
|
+
handler's ``call_frontend_tool`` invokes a client-declared tool through the
|
|
39
|
+
same ``toolCalls`` projection: the client's ``voice/temp-frontend-tool-result``
|
|
40
|
+
resolves the await instead of forwarding to the provider, and session end
|
|
41
|
+
fails outstanding awaits.
|
|
37
42
|
|
|
38
43
|
Rejections: ``voice-active`` (a session is already live), ``no-voice``
|
|
39
44
|
(no voice manager declared, or ``voice/end`` with no session),
|
|
@@ -111,12 +116,14 @@ class VoiceManager(Tray):
|
|
|
111
116
|
clientId = observable(None)
|
|
112
117
|
status = observable("connecting")
|
|
113
118
|
toolCalls = observable([])
|
|
119
|
+
activeTask = observable(None)
|
|
114
120
|
|
|
115
121
|
state = observable(
|
|
116
122
|
{
|
|
117
123
|
"clientId": clientId,
|
|
118
124
|
"status": status,
|
|
119
125
|
"toolCalls": toolCalls,
|
|
126
|
+
"activeTask": activeTask,
|
|
120
127
|
}
|
|
121
128
|
)
|
|
122
129
|
|
|
@@ -131,7 +138,8 @@ class VoiceManager(Tray):
|
|
|
131
138
|
self._seen_requests: set[str] = set()
|
|
132
139
|
self._delegating = asyncio.Lock()
|
|
133
140
|
self._tasks_run = 0
|
|
134
|
-
self._client_tools:
|
|
141
|
+
self._client_tools: "list[VoiceManager.ToolDef]" = []
|
|
142
|
+
self._executor_waits: dict[str, asyncio.Future[str]] = {}
|
|
135
143
|
|
|
136
144
|
def items(self):
|
|
137
145
|
yield from ()
|
|
@@ -224,8 +232,10 @@ class VoiceManager(Tray):
|
|
|
224
232
|
task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest",
|
|
225
233
|
*,
|
|
226
234
|
speak: Callable[[str], Awaitable[None]],
|
|
235
|
+
call_frontend_tool: Callable[[str, str], Awaitable[str]],
|
|
236
|
+
tools: "list[VoiceManager.ToolDef]",
|
|
227
237
|
) -> str:
|
|
228
|
-
"""Fulfill one delegated work unit; ``speak`` streams interim spoken updates and the return value is the result."""
|
|
238
|
+
"""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."""
|
|
229
239
|
...
|
|
230
240
|
|
|
231
241
|
# ─── Session lifecycle ──────────────────────────────────
|
|
@@ -236,7 +246,7 @@ class VoiceManager(Tray):
|
|
|
236
246
|
applied: Callable[[], None],
|
|
237
247
|
) -> Any:
|
|
238
248
|
self.clientId.value = caller.client_id
|
|
239
|
-
self._client_tools =
|
|
249
|
+
self._client_tools = tools
|
|
240
250
|
try:
|
|
241
251
|
provider = await self.handler(
|
|
242
252
|
VoiceManager.SessionContext(client_id=caller.client_id)
|
|
@@ -286,6 +296,11 @@ class VoiceManager(Tray):
|
|
|
286
296
|
turns, self._turns = self._turns, []
|
|
287
297
|
for turn in turns:
|
|
288
298
|
self._host.drop_ephemeral_message(turn["id"])
|
|
299
|
+
waits, self._executor_waits = self._executor_waits, {}
|
|
300
|
+
for wait in waits.values():
|
|
301
|
+
if not wait.done():
|
|
302
|
+
wait.set_exception(RuntimeError("the voice session ended"))
|
|
303
|
+
wait.exception()
|
|
289
304
|
self.status.value = "closed"
|
|
290
305
|
self.delete()
|
|
291
306
|
|
|
@@ -382,8 +397,9 @@ class VoiceManager(Tray):
|
|
|
382
397
|
follow_up=self._tasks_run > 0,
|
|
383
398
|
)
|
|
384
399
|
self._tasks_run += 1
|
|
400
|
+
self.activeTask.value = {"taskId": task_id, "text": text}
|
|
385
401
|
try:
|
|
386
|
-
answer = await self.
|
|
402
|
+
answer = await self._delegate(task)
|
|
387
403
|
if not isinstance(answer, str) or answer == "":
|
|
388
404
|
raise TypeError(
|
|
389
405
|
f"handle_voice_task must return the result text, got {answer!r}"
|
|
@@ -395,40 +411,76 @@ class VoiceManager(Tray):
|
|
|
395
411
|
channel="commentary",
|
|
396
412
|
)
|
|
397
413
|
return
|
|
414
|
+
finally:
|
|
415
|
+
self.activeTask.value = None
|
|
398
416
|
await self._append(answer, channel="speakable")
|
|
399
417
|
|
|
418
|
+
async def _delegate(
|
|
419
|
+
self, task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest"
|
|
420
|
+
) -> str:
|
|
421
|
+
return await self._host.handle_voice_task(
|
|
422
|
+
task,
|
|
423
|
+
speak=self._speak,
|
|
424
|
+
call_frontend_tool=self._call_frontend_tool,
|
|
425
|
+
tools=self._client_tools,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def _project_call(self, call_id: str, name: str, arguments: str) -> None:
|
|
429
|
+
self.toolCalls.value = [
|
|
430
|
+
*self.toolCalls.value,
|
|
431
|
+
{"callId": call_id, "name": name, "arguments": arguments},
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
def _clear_call(self, call_id: str) -> None:
|
|
435
|
+
if self._ended:
|
|
436
|
+
return
|
|
437
|
+
self.toolCalls.value = [
|
|
438
|
+
call for call in self.toolCalls.value if call["callId"] != call_id
|
|
439
|
+
]
|
|
440
|
+
|
|
400
441
|
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
|
|
401
442
|
if self._ended or not self._claim(request.call_id):
|
|
402
443
|
return
|
|
403
|
-
if request.name in self._client_tools:
|
|
404
|
-
self.
|
|
405
|
-
*self.toolCalls.value,
|
|
406
|
-
{
|
|
407
|
-
"callId": request.call_id,
|
|
408
|
-
"name": request.name,
|
|
409
|
-
"arguments": request.arguments,
|
|
410
|
-
},
|
|
411
|
-
]
|
|
444
|
+
if any(tool["name"] == request.name for tool in self._client_tools):
|
|
445
|
+
self._project_call(request.call_id, request.name, request.arguments)
|
|
412
446
|
return
|
|
413
447
|
self._spawn(self._run_tool_call(request))
|
|
414
448
|
|
|
449
|
+
async def _call_frontend_tool(self, name: str, arguments: str) -> str:
|
|
450
|
+
if all(tool["name"] != name for tool in self._client_tools):
|
|
451
|
+
raise ValueError(f"{name!r} is not a client-declared frontend tool")
|
|
452
|
+
if self._ended:
|
|
453
|
+
raise RuntimeError("the voice session ended")
|
|
454
|
+
call_id = uuid4().hex
|
|
455
|
+
wait: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
456
|
+
self._executor_waits[call_id] = wait
|
|
457
|
+
self._project_call(call_id, name, arguments)
|
|
458
|
+
try:
|
|
459
|
+
return await wait
|
|
460
|
+
finally:
|
|
461
|
+
self._executor_waits.pop(call_id, None)
|
|
462
|
+
self._clear_call(call_id)
|
|
463
|
+
|
|
415
464
|
async def _answer_client_call(self, call_id: str, output: str) -> None:
|
|
416
|
-
|
|
417
|
-
if not any(call["callId"] == call_id for call in pending):
|
|
465
|
+
if not any(call["callId"] == call_id for call in self.toolCalls.value):
|
|
418
466
|
raise _reject(
|
|
419
467
|
"unknown-tool-call", f"no pending frontend tool call {call_id!r}"
|
|
420
468
|
)
|
|
469
|
+
wait = self._executor_waits.get(call_id)
|
|
470
|
+
if wait is not None:
|
|
471
|
+
if not wait.done():
|
|
472
|
+
wait.set_result(output)
|
|
473
|
+
return
|
|
421
474
|
provider = self._provider
|
|
422
475
|
if provider is None:
|
|
423
476
|
raise _reject("no-voice", "the voice session is not live")
|
|
424
477
|
await provider.return_tool_result(call_id, output)
|
|
425
|
-
self.
|
|
426
|
-
call for call in pending if call["callId"] != call_id
|
|
427
|
-
]
|
|
478
|
+
self._clear_call(call_id)
|
|
428
479
|
|
|
429
480
|
async def _run_tool_call(self, request: "VoiceManager.ToolRequest") -> None:
|
|
481
|
+
self.activeTask.value = {"taskId": request.call_id, "text": request.name}
|
|
430
482
|
try:
|
|
431
|
-
output = await self.
|
|
483
|
+
output = await self._delegate(request)
|
|
432
484
|
if not isinstance(output, str):
|
|
433
485
|
raise TypeError(
|
|
434
486
|
f"handle_voice_task must return the result text, got {output!r}"
|
|
@@ -438,6 +490,8 @@ class VoiceManager(Tray):
|
|
|
438
490
|
"voice session %r tool call %r failed", self.id, request.call_id
|
|
439
491
|
)
|
|
440
492
|
output = json.dumps({"error": f"{type(exc).__name__}: {exc}"})
|
|
493
|
+
finally:
|
|
494
|
+
self.activeTask.value = None
|
|
441
495
|
provider = self._provider
|
|
442
496
|
if provider is not None:
|
|
443
497
|
await provider.return_tool_result(request.call_id, output)
|
|
@@ -39,7 +39,12 @@ async def test_start_seeds_provider_and_projects_session():
|
|
|
39
39
|
assert bench.provider.calls == [("start", OFFER, bench.transcript, [])]
|
|
40
40
|
await drv.wait(lambda s: (voice_of(s) or {}).get("status") == "live")
|
|
41
41
|
voice = voice_of(drv.replica)
|
|
42
|
-
assert voice == {
|
|
42
|
+
assert voice == {
|
|
43
|
+
"clientId": drv.client_id,
|
|
44
|
+
"status": "live",
|
|
45
|
+
"toolCalls": [],
|
|
46
|
+
"activeTask": None,
|
|
47
|
+
}
|
|
43
48
|
assert bench.contexts[0].client_id == drv.client_id
|
|
44
49
|
|
|
45
50
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""Voice delegation contract: work units route to ``Host.handle_voice_task``,
|
|
2
2
|
handoffs drain the transcript incrementally, results flow back as speakable
|
|
3
|
-
context appends, named calls answer via ``return_tool_result``,
|
|
4
|
-
request ids run once
|
|
3
|
+
context appends, named calls answer via ``return_tool_result``, repeated
|
|
4
|
+
request ids run once, and the running unit projects as ``activeTask``."""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
7
|
import json
|
|
8
8
|
|
|
9
|
-
from voice_helpers import OFFER, Bench, voice_host
|
|
9
|
+
from voice_helpers import OFFER, Bench, voice_host, voice_of
|
|
10
10
|
|
|
11
11
|
from harness_sdk import VoiceManager
|
|
12
12
|
|
|
@@ -142,6 +142,66 @@ async def test_task_failure_surfaces_as_commentary():
|
|
|
142
142
|
]
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
async def test_active_task_projects_while_a_task_runs_and_clears_after():
|
|
146
|
+
bench = Bench()
|
|
147
|
+
gate = asyncio.Event()
|
|
148
|
+
|
|
149
|
+
async def handler(task, speak):
|
|
150
|
+
await gate.wait()
|
|
151
|
+
return "all done"
|
|
152
|
+
|
|
153
|
+
bench.task_handler = handler
|
|
154
|
+
async with voice_host(bench) as (drv, _app):
|
|
155
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
156
|
+
bench.events().task_requested("d1", "check the weather")
|
|
157
|
+
await drv.wait(
|
|
158
|
+
lambda s: (voice_of(s) or {}).get("activeTask")
|
|
159
|
+
== {"taskId": "d1", "text": "check the weather"}
|
|
160
|
+
)
|
|
161
|
+
gate.set()
|
|
162
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("activeTask") is None)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def test_active_task_projects_a_named_call_and_clears_after():
|
|
166
|
+
bench = Bench()
|
|
167
|
+
gate = asyncio.Event()
|
|
168
|
+
|
|
169
|
+
async def handler(task, speak):
|
|
170
|
+
await gate.wait()
|
|
171
|
+
return "{}"
|
|
172
|
+
|
|
173
|
+
bench.task_handler = handler
|
|
174
|
+
async with voice_host(bench) as (drv, _app):
|
|
175
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
176
|
+
bench.events().tool_requested(
|
|
177
|
+
VoiceManager.ToolRequest(call_id="c1", name="get_time", arguments="{}")
|
|
178
|
+
)
|
|
179
|
+
await drv.wait(
|
|
180
|
+
lambda s: (voice_of(s) or {}).get("activeTask")
|
|
181
|
+
== {"taskId": "c1", "text": "get_time"}
|
|
182
|
+
)
|
|
183
|
+
gate.set()
|
|
184
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("activeTask") is None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def test_active_task_clears_when_the_handler_fails():
|
|
188
|
+
bench = Bench()
|
|
189
|
+
gate = asyncio.Event()
|
|
190
|
+
|
|
191
|
+
async def handler(task, speak):
|
|
192
|
+
await gate.wait()
|
|
193
|
+
raise RuntimeError("boom")
|
|
194
|
+
|
|
195
|
+
bench.task_handler = handler
|
|
196
|
+
async with voice_host(bench) as (drv, _app):
|
|
197
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
198
|
+
bench.events().task_requested("d1", "dig in")
|
|
199
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("activeTask") is not None)
|
|
200
|
+
gate.set()
|
|
201
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("activeTask") is None)
|
|
202
|
+
assert (voice_of(drv.replica) or {}).get("status") == "live"
|
|
203
|
+
|
|
204
|
+
|
|
145
205
|
async def test_tool_failure_returns_an_error_output():
|
|
146
206
|
bench = Bench()
|
|
147
207
|
|
|
@@ -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):
|
|
@@ -72,6 +72,8 @@ class Bench:
|
|
|
72
72
|
contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
|
|
73
73
|
tasks: list[Any] = field(default_factory=list)
|
|
74
74
|
task_handler: Any = None
|
|
75
|
+
call_frontend_tool: Any = None
|
|
76
|
+
tool_defs: list[Any] = field(default_factory=list)
|
|
75
77
|
|
|
76
78
|
def events(self) -> VoiceManager.Events:
|
|
77
79
|
assert self.provider.events is not None, "provider.start never ran"
|
|
@@ -116,8 +118,10 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
|
116
118
|
del self._ephemeral[message_id]
|
|
117
119
|
self._project_messages()
|
|
118
120
|
|
|
119
|
-
async def handle_voice_task(self, task, *, speak):
|
|
121
|
+
async def handle_voice_task(self, task, *, speak, call_frontend_tool, tools):
|
|
120
122
|
bench.tasks.append(task)
|
|
123
|
+
bench.call_frontend_tool = call_frontend_tool
|
|
124
|
+
bench.tool_defs = tools
|
|
121
125
|
if bench.task_handler is not None:
|
|
122
126
|
return await bench.task_handler(task, speak)
|
|
123
127
|
request_id = getattr(task, "task_id", None) or task.call_id
|
|
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
|