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.
Files changed (44) hide show
  1. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/voice.py +75 -21
  4. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_voice.py +6 -1
  5. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_voice_delegation.py +63 -3
  6. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_voice_frontend_tools.py +89 -2
  7. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/voice_helpers.py +5 -1
  8. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/.gitignore +0 -0
  9. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/README.md +0 -0
  10. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/checkpoints.py +0 -0
  14. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/deepagents.py +0 -0
  15. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/fenced_postgres.py +0 -0
  16. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/linear_thread.py +0 -0
  17. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/src/harness_sdk/run_manager.py +0 -0
  18. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/run_helpers.py +0 -0
  19. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_admit_steer.py +0 -0
  20. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_applied_visibility.py +0 -0
  21. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_batches.py +0 -0
  22. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_branch_anchor.py +0 -0
  23. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_checkpoints.py +0 -0
  24. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_deepagents_messages.py +0 -0
  25. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_dispatching.py +0 -0
  26. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_durability.py +0 -0
  27. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_edit_dispatched.py +0 -0
  28. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_edit_reload.py +0 -0
  29. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_enqueue.py +0 -0
  30. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_fenced_postgres.py +0 -0
  31. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_input_required.py +0 -0
  32. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_linear_thread.py +0 -0
  33. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_meta.py +0 -0
  34. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_mint_concurrency.py +0 -0
  35. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_outcomes.py +0 -0
  36. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_placement.py +0 -0
  37. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_prepare_hooks.py +0 -0
  38. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_restore.py +0 -0
  39. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_rewind_during_run.py +0 -0
  40. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_run_crash_sweep.py +0 -0
  41. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_settle.py +0 -0
  42. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_steer.py +0 -0
  43. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_stop_continue.py +0 -0
  44. {harness_sdk_python-0.30.0 → harness_sdk_python-0.31.0}/tests/test_stopping_window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.30.0
3
+ Version: 0.31.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.30.0"
3
+ version = "0.31.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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: set[str] = set()
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 = {tool["name"] for tool in 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._host.handle_voice_task(task, speak=self._speak)
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.toolCalls.value = [
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
- pending = self.toolCalls.value
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.toolCalls.value = [
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._host.handle_voice_task(request, speak=self._speak)
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 == {"clientId": drv.client_id, "status": "live", "toolCalls": []}
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``, and repeated
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"]``, and
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