harness-sdk-python 0.29.1__tar.gz → 0.30.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.29.1 → harness_sdk_python-0.30.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/__init__.py +2 -0
  4. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/voice.py +225 -12
  5. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_voice.py +39 -8
  6. harness_sdk_python-0.30.0/tests/test_voice_delegation.py +164 -0
  7. harness_sdk_python-0.30.0/tests/test_voice_frontend_tools.py +109 -0
  8. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/voice_helpers.py +22 -3
  9. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/.gitignore +0 -0
  10. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/README.md +0 -0
  11. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/examples/__init__.py +0 -0
  12. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/examples/runs_app.py +0 -0
  13. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/checkpoints.py +0 -0
  14. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/deepagents.py +0 -0
  15. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/fenced_postgres.py +0 -0
  16. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/linear_thread.py +0 -0
  17. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/run_manager.py +0 -0
  18. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/run_helpers.py +0 -0
  19. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_admit_steer.py +0 -0
  20. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_applied_visibility.py +0 -0
  21. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_batches.py +0 -0
  22. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_branch_anchor.py +0 -0
  23. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_checkpoints.py +0 -0
  24. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_deepagents_messages.py +0 -0
  25. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_dispatching.py +0 -0
  26. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_durability.py +0 -0
  27. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_edit_dispatched.py +0 -0
  28. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_edit_reload.py +0 -0
  29. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_enqueue.py +0 -0
  30. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_fenced_postgres.py +0 -0
  31. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_input_required.py +0 -0
  32. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_linear_thread.py +0 -0
  33. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_meta.py +0 -0
  34. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_mint_concurrency.py +0 -0
  35. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_outcomes.py +0 -0
  36. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_placement.py +0 -0
  37. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_prepare_hooks.py +0 -0
  38. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_restore.py +0 -0
  39. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_rewind_during_run.py +0 -0
  40. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_run_crash_sweep.py +0 -0
  41. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_settle.py +0 -0
  42. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_steer.py +0 -0
  43. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_stop_continue.py +0 -0
  44. {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.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.29.1
3
+ Version: 0.30.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.29.1"
3
+ version = "0.30.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,6 +20,7 @@ from .voice import (
20
20
  VoiceProvider,
21
21
  end_voice,
22
22
  start_voice,
23
+ temp_frontend_tool_result,
23
24
  voice_manager,
24
25
  )
25
26
 
@@ -45,5 +46,6 @@ __all__ = [
45
46
  "linear_thread",
46
47
  "run_manager",
47
48
  "start_voice",
49
+ "temp_frontend_tool_result",
48
50
  "voice_manager",
49
51
  ]
@@ -7,10 +7,15 @@ lives in its own ephemeral tray, minted by ``voice/start`` and deleted when
7
7
  the session ends. The host implements ``VoiceManager.Host``: the seed read
8
8
  and the turn commit both stay host-owned.
9
9
 
10
- ``voice/start {offer}`` seeds the provider from the thread transcript (at
11
- most 128 messages, tail taken) and projects ``state["voice"] = {clientId,
12
- status}`` — the SDP answer rides only the command's result payload
13
- ``{answer}``. The session is bound to the issuing client's connection: ``voice/end`` from the owner, a
10
+ ``voice/start {offer, tools?}`` seeds the provider from the thread
11
+ transcript (at most 128 messages, tail taken), hands the client's frontend
12
+ function tool definitions to the provider, and projects ``state["voice"] =
13
+ {clientId, status, toolCalls}`` — the SDP answer rides only the command's
14
+ result payload ``{answer}``. A named call matching a client-declared tool
15
+ projects into ``toolCalls`` as ``{callId, name, arguments}``; the owning
16
+ client answers it with ``voice/temp-frontend-tool-result {call_id,
17
+ output}``, which forwards through ``provider.return_tool_result`` and
18
+ clears the entry. The session is bound to the issuing client's connection: ``voice/end`` from the owner, a
14
19
  provider ``closed`` event, or a connection drop ends it. Provider turn
15
20
  events render live in ``state["messages"]`` through
16
21
  ``Host.upsert_ephemeral_message`` (message id = turn id), tagged
@@ -18,7 +23,17 @@ events render live in ``state["messages"]`` through
18
23
  turn commits through ``Host.commit_voice_turns`` (serialized, in
19
24
  done-order) and the durable entry replaces the ephemeral one under the
20
25
  same id, a failed commit keeps its turns for the next flush; session end
21
- flushes the remaining done turns and drops in-progress turns.
26
+ flushes the remaining done turns, then commits in-progress turns with
27
+ their partial text; in-progress turns with no text yet are dropped.
28
+
29
+ Delegated work: provider ``task_requested`` (free-text unit) and
30
+ ``tool_requested`` (named call) events route to ``Host.handle_voice_task``,
31
+ deduped by id. Tasks run one at a time and each carries an incremental
32
+ transcript handoff — the first task the conversation so far, follow-ups the
33
+ turns since the previous task. Interim ``speak`` text and the returned
34
+ result flow back as speakable context appends; a named call's result
35
+ returns via ``provider.return_tool_result``; a failed handler surfaces to
36
+ the model as commentary (task) or an error output (call).
22
37
 
23
38
  Rejections: ``voice-active`` (a session is already live), ``no-voice``
24
39
  (no voice manager declared, or ``voice/end`` with no session),
@@ -29,9 +44,10 @@ Rejections: ``voice-active`` (a session is already live), ``no-voice``
29
44
  import asyncio
30
45
  import functools
31
46
  import inspect
47
+ import json
32
48
  import logging
33
49
  from dataclasses import dataclass
34
- from typing import Any, Callable, Literal, Protocol, TypedDict
50
+ from typing import Any, Awaitable, Callable, Literal, Protocol, TypedDict
35
51
  from uuid import uuid4
36
52
 
37
53
  from pinned.trays import Tray, TrayDescriptor, TrayMapNamespace
@@ -48,6 +64,30 @@ def _reject(reason: str, message: str) -> StatewireReject:
48
64
  return StatewireReject(message, payload={"reason": reason})
49
65
 
50
66
 
67
+ def _validated_tools(tools: Any) -> "list[VoiceManager.ToolDef]":
68
+ if tools is None:
69
+ return []
70
+ if not isinstance(tools, list):
71
+ raise _reject("invalid-message", "tools must be a list")
72
+ names: set[str] = set()
73
+ for tool in tools:
74
+ if (
75
+ not isinstance(tool, dict)
76
+ or not isinstance(tool.get("name"), str)
77
+ or tool["name"] == ""
78
+ or not isinstance(tool.get("description"), str)
79
+ or not isinstance(tool.get("parameters"), dict)
80
+ ):
81
+ raise _reject(
82
+ "invalid-message",
83
+ f"tools entries must be {{name, description, parameters}}, got {tool!r}",
84
+ )
85
+ if tool["name"] in names:
86
+ raise _reject("invalid-message", f"duplicate tool name {tool['name']!r}")
87
+ names.add(tool["name"])
88
+ return tools
89
+
90
+
51
91
  def _capped_seed(seed: Any) -> "list[VoiceManager.SeedMessage]":
52
92
  if not isinstance(seed, list):
53
93
  raise TypeError(f"get_voice_seed must return a list, got {seed!r}")
@@ -70,11 +110,13 @@ class VoiceManager(Tray):
70
110
 
71
111
  clientId = observable(None)
72
112
  status = observable("connecting")
113
+ toolCalls = observable([])
73
114
 
74
115
  state = observable(
75
116
  {
76
117
  "clientId": clientId,
77
118
  "status": status,
119
+ "toolCalls": toolCalls,
78
120
  }
79
121
  )
80
122
 
@@ -85,6 +127,11 @@ class VoiceManager(Tray):
85
127
  self._flushing = asyncio.Lock()
86
128
  self._ended = False
87
129
  self._watcher: "asyncio.Task[Any] | None" = None
130
+ self._handoff_pending: list[dict[str, str]] = []
131
+ self._seen_requests: set[str] = set()
132
+ self._delegating = asyncio.Lock()
133
+ self._tasks_run = 0
134
+ self._client_tools: set[str] = set()
88
135
 
89
136
  def items(self):
90
137
  yield from ()
@@ -95,12 +142,41 @@ class VoiceManager(Tray):
95
142
  role: Literal["user", "assistant", "system"]
96
143
  text: str
97
144
 
145
+ class ToolDef(TypedDict):
146
+ """One frontend function tool the client declares at ``voice/start``."""
147
+
148
+ name: str
149
+ description: str
150
+ parameters: dict[str, Any]
151
+
98
152
  @dataclass(frozen=True)
99
153
  class ToolRequest:
100
154
  call_id: str
101
155
  name: str
102
156
  arguments: str
103
157
 
158
+ @dataclass(frozen=True)
159
+ class TaskRequest:
160
+ """One delegated free-text work unit plus its transcript delta."""
161
+
162
+ task_id: str
163
+ text: str
164
+ transcript: str
165
+ follow_up: bool
166
+
167
+ def handoff(self) -> str:
168
+ """Render the task and its transcript delta as one delegate-facing prompt."""
169
+ window = (
170
+ "the voice conversation since the previous delegated task"
171
+ if self.follow_up
172
+ else "the voice conversation so far"
173
+ )
174
+ transcript = self.transcript or "(No new conversation was captured.)"
175
+ return (
176
+ f"This is the transcript of {window}:\n\n{transcript}\n\n"
177
+ f"The voice model delegated this task:\n{self.text}"
178
+ )
179
+
104
180
  @dataclass(frozen=True)
105
181
  class SessionContext:
106
182
  """The provider factory's per-session context."""
@@ -118,6 +194,8 @@ class VoiceManager(Tray):
118
194
 
119
195
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None: ...
120
196
 
197
+ def task_requested(self, task_id: str, text: str) -> None: ...
198
+
121
199
  def rollover_approaching(self, rollover_id: str, deadline: float) -> None: ...
122
200
 
123
201
  def closed(self, reason: str) -> None: ...
@@ -141,18 +219,29 @@ class VoiceManager(Tray):
141
219
  """Remove an in-flight turn from the ``state["messages"]`` projection."""
142
220
  ...
143
221
 
222
+ async def handle_voice_task(
223
+ self,
224
+ task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest",
225
+ *,
226
+ speak: Callable[[str], Awaitable[None]],
227
+ ) -> str:
228
+ """Fulfill one delegated work unit; ``speak`` streams interim spoken updates and the return value is the result."""
229
+ ...
230
+
144
231
  # ─── Session lifecycle ──────────────────────────────────
145
232
 
146
233
  async def _begin(
147
- self, offer: str, seed: "list[VoiceManager.SeedMessage]", caller: Any,
234
+ self, offer: str, seed: "list[VoiceManager.SeedMessage]",
235
+ tools: "list[VoiceManager.ToolDef]", caller: Any,
148
236
  applied: Callable[[], None],
149
237
  ) -> Any:
150
238
  self.clientId.value = caller.client_id
239
+ self._client_tools = {tool["name"] for tool in tools}
151
240
  try:
152
241
  provider = await self.handler(
153
242
  VoiceManager.SessionContext(client_id=caller.client_id)
154
243
  )
155
- answer = await provider.start(offer, seed=seed, events=self)
244
+ answer = await provider.start(offer, seed=seed, tools=tools, events=self)
156
245
  if not isinstance(answer, str) or answer == "":
157
246
  raise TypeError(f"provider.start must return an SDP answer, got {answer!r}")
158
247
  except Exception as exc:
@@ -189,6 +278,10 @@ class VoiceManager(Tray):
189
278
  await provider.close()
190
279
  except Exception:
191
280
  logger.exception("voice provider close failed for session %r", self.id)
281
+ self._turns.sort(key=lambda turn: not turn["done"])
282
+ for turn in self._turns:
283
+ if turn["text"]:
284
+ turn["done"] = True
192
285
  await self._flush()
193
286
  turns, self._turns = self._turns, []
194
287
  for turn in turns:
@@ -253,11 +346,101 @@ class VoiceManager(Tray):
253
346
  turn = self._turn(turn_id)
254
347
  turn["text"] = text
255
348
  turn["done"] = True
349
+ self._handoff_pending.append({"role": turn["role"], "text": text})
256
350
  self._render(turn)
257
351
  self._spawn(self._flush())
258
352
 
353
+ def _claim(self, request_id: str) -> bool:
354
+ if request_id in self._seen_requests:
355
+ return False
356
+ self._seen_requests.add(request_id)
357
+ return True
358
+
359
+ async def _append(
360
+ self, text: str, *, channel: Literal["speakable", "commentary"]
361
+ ) -> None:
362
+ provider = self._provider
363
+ if provider is not None:
364
+ await provider.append_context(text, channel=channel)
365
+
366
+ async def _speak(self, text: str) -> None:
367
+ await self._append(text, channel="speakable")
368
+
369
+ def task_requested(self, task_id: str, text: str) -> None:
370
+ if self._ended or not self._claim(task_id):
371
+ return
372
+ self._spawn(self._run_delegated_task(task_id, text))
373
+
374
+ async def _run_delegated_task(self, task_id: str, text: str) -> None:
375
+ # the lock orders transcript drains and keeps one delegation in flight
376
+ async with self._delegating:
377
+ pending, self._handoff_pending = self._handoff_pending, []
378
+ task = VoiceManager.TaskRequest(
379
+ task_id=task_id,
380
+ text=text,
381
+ transcript="\n".join(f"[{t['role']}] {t['text']}" for t in pending),
382
+ follow_up=self._tasks_run > 0,
383
+ )
384
+ self._tasks_run += 1
385
+ try:
386
+ answer = await self._host.handle_voice_task(task, speak=self._speak)
387
+ if not isinstance(answer, str) or answer == "":
388
+ raise TypeError(
389
+ f"handle_voice_task must return the result text, got {answer!r}"
390
+ )
391
+ except Exception as exc:
392
+ logger.exception("voice session %r task %r failed", self.id, task_id)
393
+ await self._append(
394
+ f"The delegated work could not be completed: {exc}",
395
+ channel="commentary",
396
+ )
397
+ return
398
+ await self._append(answer, channel="speakable")
399
+
259
400
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
260
- logger.info("voice session %r tool request ignored: %r", self.id, request)
401
+ if self._ended or not self._claim(request.call_id):
402
+ 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
+ ]
412
+ return
413
+ self._spawn(self._run_tool_call(request))
414
+
415
+ 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):
418
+ raise _reject(
419
+ "unknown-tool-call", f"no pending frontend tool call {call_id!r}"
420
+ )
421
+ provider = self._provider
422
+ if provider is None:
423
+ raise _reject("no-voice", "the voice session is not live")
424
+ await provider.return_tool_result(call_id, output)
425
+ self.toolCalls.value = [
426
+ call for call in pending if call["callId"] != call_id
427
+ ]
428
+
429
+ async def _run_tool_call(self, request: "VoiceManager.ToolRequest") -> None:
430
+ try:
431
+ output = await self._host.handle_voice_task(request, speak=self._speak)
432
+ if not isinstance(output, str):
433
+ raise TypeError(
434
+ f"handle_voice_task must return the result text, got {output!r}"
435
+ )
436
+ except Exception as exc:
437
+ logger.exception(
438
+ "voice session %r tool call %r failed", self.id, request.call_id
439
+ )
440
+ output = json.dumps({"error": f"{type(exc).__name__}: {exc}"})
441
+ provider = self._provider
442
+ if provider is not None:
443
+ await provider.return_tool_result(request.call_id, output)
261
444
 
262
445
  def rollover_approaching(self, rollover_id: str, deadline: float) -> None:
263
446
  logger.info(
@@ -277,6 +460,7 @@ class VoiceProvider(Protocol):
277
460
  async def start(
278
461
  self, offer: str, *,
279
462
  seed: "list[VoiceManager.SeedMessage]",
463
+ tools: "list[VoiceManager.ToolDef]",
280
464
  events: "VoiceManager.Events",
281
465
  ) -> str:
282
466
  """Open the session from an SDP offer and return the SDP answer; events flow into the sink."""
@@ -306,6 +490,7 @@ class VoiceManagerNamespace(TrayMapNamespace):
306
490
  "commit_voice_turns",
307
491
  "upsert_ephemeral_message",
308
492
  "drop_ephemeral_message",
493
+ "handle_voice_task",
309
494
  ):
310
495
  if not callable(getattr(api, name, None)):
311
496
  raise TypeError(
@@ -338,6 +523,7 @@ class VoiceManagerNamespace(TrayMapNamespace):
338
523
  offer = params.get("offer")
339
524
  if not isinstance(offer, str) or offer == "":
340
525
  raise _reject("invalid-message", "offer must be a non-empty SDP string")
526
+ tools = _validated_tools(params.get("tools"))
341
527
  if self._starting or self.live() is not None:
342
528
  raise _reject("voice-active", "a voice session is already live")
343
529
  self._starting = True
@@ -345,19 +531,38 @@ class VoiceManagerNamespace(TrayMapNamespace):
345
531
  seed = _capped_seed(await self._api.get_voice_seed())
346
532
  tray = await self.get_or_create(uuid4().hex)
347
533
  assert isinstance(tray, VoiceManager)
348
- return await tray._begin(offer, seed, ctx.caller, ctx.applied)
534
+ return await tray._begin(offer, seed, tools, ctx.caller, ctx.applied)
349
535
  finally:
350
536
  self._starting = False
351
537
 
352
- async def end(self, *, ctx: Any) -> None:
538
+ def _owned_session(self, ctx: Any, command: str) -> VoiceManager:
353
539
  session = self.live()
354
540
  if session is None:
355
541
  raise _reject("no-voice", "no voice session is live")
356
542
  if session.clientId.value != ctx.caller.client_id:
357
- raise _reject("not-voice-owner", "voice/end must come from the session owner")
543
+ raise _reject(
544
+ "not-voice-owner", f"{command} must come from the session owner"
545
+ )
546
+ return session
547
+
548
+ async def end(self, *, ctx: Any) -> None:
549
+ session = self._owned_session(ctx, "voice/end")
358
550
  await session._end("client")
359
551
  ctx.applied()
360
552
 
553
+ async def temp_frontend_tool_result(self, params: Any, *, ctx: Any) -> None:
554
+ session = self._owned_session(ctx, "voice/temp-frontend-tool-result")
555
+ if not isinstance(params, dict):
556
+ raise _reject("invalid-message", "params must be an object")
557
+ call_id = params.get("call_id")
558
+ output = params.get("output")
559
+ if not isinstance(call_id, str) or call_id == "" or not isinstance(output, str):
560
+ raise _reject(
561
+ "invalid-message", "call_id and output must be non-empty strings"
562
+ )
563
+ await session._answer_client_call(call_id, output)
564
+ ctx.applied()
565
+
361
566
 
362
567
  class VoiceManagerDescriptor(TrayDescriptor):
363
568
  namespace_cls = VoiceManagerNamespace
@@ -414,3 +619,11 @@ async def end_voice(host: Any, *, ctx: Any) -> None:
414
619
  if namespace is None:
415
620
  raise _reject("no-voice", "this host declares no voice manager")
416
621
  return await namespace.end(ctx=ctx)
622
+
623
+
624
+ async def temp_frontend_tool_result(host: Any, params: Any, *, ctx: Any) -> None:
625
+ """The ``voice/temp-frontend-tool-result`` command body: routes to the host's declared voice manager."""
626
+ namespace = _voice_of(host)
627
+ if namespace is None:
628
+ raise _reject("no-voice", "this host declares no voice manager")
629
+ return await namespace.temp_frontend_tool_result(params, ctx=ctx)
@@ -36,10 +36,10 @@ async def test_start_seeds_provider_and_projects_session():
36
36
  rsp = await drv.command("voice/start", {"offer": OFFER})
37
37
  assert rsp["type"] == "accepted", rsp
38
38
  assert rsp["payload"] == {"answer": ANSWER}
39
- assert bench.provider.calls == [("start", OFFER, bench.transcript)]
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"}
42
+ assert voice == {"clientId": drv.client_id, "status": "live", "toolCalls": []}
43
43
  assert bench.contexts[0].client_id == drv.client_id
44
44
 
45
45
 
@@ -186,7 +186,7 @@ async def test_commit_failure_retries_in_order_with_the_next_turn():
186
186
  ]
187
187
 
188
188
 
189
- async def test_end_drops_in_progress_turns():
189
+ async def test_end_commits_in_progress_turns_with_partial_text():
190
190
  bench = Bench()
191
191
  async with voice_host(bench) as (drv, _app):
192
192
  await drv.command("voice/start", {"offer": OFFER})
@@ -199,15 +199,41 @@ async def test_end_drops_in_progress_turns():
199
199
  rsp = await drv.command("voice/end")
200
200
  assert rsp["type"] == "accepted", rsp
201
201
  assert bench.provider.closed
202
- assert bench.commits == [[{"id": "t1", "role": "user", "text": "question"}]]
202
+ assert bench.commits == [
203
+ [{"id": "t1", "role": "user", "text": "question"}],
204
+ [{"id": "t2", "role": "assistant", "text": "half an ans"}],
205
+ ]
206
+ # the durable entries replaced the ephemeral ones under the same ids
203
207
  await drv.wait(
204
208
  lambda s: voice_of(s) is None
205
209
  and messages_of(s)
206
- == [{"id": "t1", "type": "human", "content": "question"}]
210
+ == [
211
+ {"id": "t1", "type": "human", "content": "question"},
212
+ {"id": "t2", "type": "ai", "content": "half an ans"},
213
+ ]
207
214
  )
208
215
 
209
216
 
210
- async def test_end_with_no_done_turns_commits_nothing():
217
+ async def test_end_commits_partial_turns_after_kept_done_turns():
218
+ bench = Bench(commit_failures=[RuntimeError("checkpointer down")])
219
+ async with voice_host(bench) as (drv, _app):
220
+ await drv.command("voice/start", {"offer": OFFER})
221
+ events = bench.events()
222
+ events.turn_started("t1", "user")
223
+ events.turn_done("t1", "question")
224
+ await until(lambda: bench.commit_failures == [])
225
+ events.turn_started("t2", "assistant")
226
+ events.turn_delta("t2", "half an ans")
227
+ await drv.command("voice/end")
228
+ assert bench.commits == [
229
+ [
230
+ {"id": "t1", "role": "user", "text": "question"},
231
+ {"id": "t2", "role": "assistant", "text": "half an ans"},
232
+ ]
233
+ ]
234
+
235
+
236
+ async def test_end_drops_started_turns_with_no_text():
211
237
  bench = Bench()
212
238
  async with voice_host(bench) as (drv, _app):
213
239
  await drv.command("voice/start", {"offer": OFFER})
@@ -234,9 +260,14 @@ async def test_provider_closed_event_ends_and_commits():
234
260
  events = bench.events()
235
261
  events.turn_started("t1", "assistant")
236
262
  events.turn_done("t1", "goodbye")
263
+ events.turn_started("t2", "user")
264
+ events.turn_delta("t2", "wai")
237
265
  events.closed("server")
238
266
  await drv.wait(lambda s: voice_of(s) is None)
239
- assert bench.commits == [[{"id": "t1", "role": "assistant", "text": "goodbye"}]]
267
+ assert bench.commits == [
268
+ [{"id": "t1", "role": "assistant", "text": "goodbye"}],
269
+ [{"id": "t2", "role": "user", "text": "wai"}],
270
+ ]
240
271
  assert_rejected(await drv.command("voice/end"), "no-voice")
241
272
 
242
273
 
@@ -267,7 +298,7 @@ async def test_seed_takes_the_128_tail():
267
298
  bench = Bench(transcript=[seed_message(i) for i in range(130)])
268
299
  async with voice_host(bench) as (drv, _app):
269
300
  await drv.command("voice/start", {"offer": OFFER})
270
- _, _, seed = bench.provider.calls[0]
301
+ _, _, seed, _tools = bench.provider.calls[0]
271
302
  assert len(seed) == 128
272
303
  assert seed == bench.transcript[-128:]
273
304
 
@@ -0,0 +1,164 @@
1
+ """Voice delegation contract: work units route to ``Host.handle_voice_task``,
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."""
5
+
6
+ import asyncio
7
+ import json
8
+
9
+ from voice_helpers import OFFER, Bench, voice_host
10
+
11
+ from harness_sdk import VoiceManager
12
+
13
+
14
+ async def until(predicate, timeout: float = 5) -> None:
15
+ deadline = asyncio.get_running_loop().time() + timeout
16
+ while not predicate():
17
+ assert asyncio.get_running_loop().time() < deadline, "condition never held"
18
+ await asyncio.sleep(0.01)
19
+
20
+
21
+ def appends(bench: Bench):
22
+ return [call for call in bench.provider.calls if call[0] == "append_context"]
23
+
24
+
25
+ async def test_task_routes_to_handler_and_result_appends_speakable():
26
+ bench = Bench()
27
+ async with voice_host(bench) as (drv, _app):
28
+ await drv.command("voice/start", {"offer": OFFER})
29
+ bench.events().task_requested("d1", "check the weather")
30
+ await until(
31
+ lambda: ("append_context", "done:d1", "speakable") in bench.provider.calls
32
+ )
33
+ assert bench.tasks == [
34
+ VoiceManager.TaskRequest(
35
+ task_id="d1", text="check the weather", transcript="", follow_up=False
36
+ )
37
+ ]
38
+
39
+
40
+ async def test_handoff_carries_first_full_then_delta_transcript():
41
+ bench = Bench()
42
+ async with voice_host(bench) as (drv, _app):
43
+ await drv.command("voice/start", {"offer": OFFER})
44
+ events = bench.events()
45
+ events.turn_started("t1", "user")
46
+ events.turn_done("t1", "hello")
47
+ events.turn_started("t2", "assistant")
48
+ events.turn_done("t2", "hi!")
49
+ events.task_requested("d1", "first task")
50
+ await until(lambda: len(bench.tasks) == 1)
51
+ first = bench.tasks[0]
52
+ assert first.transcript == "[user] hello\n[assistant] hi!"
53
+ assert first.follow_up is False
54
+ assert "the voice conversation so far" in first.handoff()
55
+ assert first.handoff().endswith(
56
+ "The voice model delegated this task:\nfirst task"
57
+ )
58
+ events.turn_started("t3", "user")
59
+ events.turn_done("t3", "and now?")
60
+ events.task_requested("d2", "second task")
61
+ await until(lambda: len(bench.tasks) == 2)
62
+ second = bench.tasks[1]
63
+ assert second.transcript == "[user] and now?"
64
+ assert second.follow_up is True
65
+ assert "since the previous delegated task" in second.handoff()
66
+
67
+
68
+ async def test_empty_handoff_renders_a_placeholder():
69
+ task = VoiceManager.TaskRequest(
70
+ task_id="d1", text="go", transcript="", follow_up=False
71
+ )
72
+ assert "(No new conversation was captured.)" in task.handoff()
73
+
74
+
75
+ async def test_speak_streams_interim_updates_before_the_result():
76
+ bench = Bench()
77
+
78
+ async def handler(task, speak):
79
+ await speak("working on it")
80
+ return "all done"
81
+
82
+ bench.task_handler = handler
83
+ async with voice_host(bench) as (drv, _app):
84
+ await drv.command("voice/start", {"offer": OFFER})
85
+ bench.events().task_requested("d1", "dig in")
86
+ await until(
87
+ lambda: ("append_context", "all done", "speakable") in bench.provider.calls
88
+ )
89
+ assert appends(bench) == [
90
+ ("append_context", "working on it", "speakable"),
91
+ ("append_context", "all done", "speakable"),
92
+ ]
93
+
94
+
95
+ async def test_named_call_answers_via_return_tool_result():
96
+ bench = Bench()
97
+ async with voice_host(bench) as (drv, _app):
98
+ await drv.command("voice/start", {"offer": OFFER})
99
+ request = VoiceManager.ToolRequest(call_id="c1", name="get_time", arguments="{}")
100
+ bench.events().tool_requested(request)
101
+ await until(
102
+ lambda: ("return_tool_result", "c1", "done:c1") in bench.provider.calls
103
+ )
104
+ assert bench.tasks == [request]
105
+ assert appends(bench) == []
106
+
107
+
108
+ async def test_repeated_request_ids_run_once():
109
+ bench = Bench()
110
+ async with voice_host(bench) as (drv, _app):
111
+ await drv.command("voice/start", {"offer": OFFER})
112
+ events = bench.events()
113
+ events.task_requested("d1", "one")
114
+ events.task_requested("d1", "one")
115
+ request = VoiceManager.ToolRequest(call_id="c1", name="get_time", arguments="{}")
116
+ events.tool_requested(request)
117
+ events.tool_requested(request)
118
+ await until(
119
+ lambda: ("append_context", "done:d1", "speakable") in bench.provider.calls
120
+ and ("return_tool_result", "c1", "done:c1") in bench.provider.calls
121
+ )
122
+ assert len(bench.tasks) == 2
123
+
124
+
125
+ async def test_task_failure_surfaces_as_commentary():
126
+ bench = Bench()
127
+
128
+ async def handler(task, speak):
129
+ raise RuntimeError("boom")
130
+
131
+ bench.task_handler = handler
132
+ async with voice_host(bench) as (drv, _app):
133
+ await drv.command("voice/start", {"offer": OFFER})
134
+ bench.events().task_requested("d1", "dig in")
135
+ await until(lambda: appends(bench) != [])
136
+ assert appends(bench) == [
137
+ (
138
+ "append_context",
139
+ "The delegated work could not be completed: boom",
140
+ "commentary",
141
+ )
142
+ ]
143
+
144
+
145
+ async def test_tool_failure_returns_an_error_output():
146
+ bench = Bench()
147
+
148
+ async def handler(task, speak):
149
+ raise RuntimeError("boom")
150
+
151
+ bench.task_handler = handler
152
+ async with voice_host(bench) as (drv, _app):
153
+ await drv.command("voice/start", {"offer": OFFER})
154
+ bench.events().tool_requested(
155
+ VoiceManager.ToolRequest(call_id="c1", name="get_time", arguments="{}")
156
+ )
157
+ await until(
158
+ lambda: any(c[0] == "return_tool_result" for c in bench.provider.calls)
159
+ )
160
+ [(_, call_id, output)] = [
161
+ c for c in bench.provider.calls if c[0] == "return_tool_result"
162
+ ]
163
+ assert call_id == "c1"
164
+ assert json.loads(output) == {"error": "RuntimeError: boom"}
@@ -0,0 +1,109 @@
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."""
4
+
5
+ from run_helpers import assert_rejected
6
+ from voice_helpers import (
7
+ OFFER,
8
+ Bench,
9
+ side_command,
10
+ voice_host,
11
+ voice_of,
12
+ )
13
+
14
+ from harness_sdk import VoiceManager
15
+
16
+ TOOL = {
17
+ "name": "get_local_time",
18
+ "description": "Read the user's local date and time.",
19
+ "parameters": {"type": "object", "properties": {}},
20
+ }
21
+
22
+
23
+ def call_of(events, call_id: str = "call-1") -> None:
24
+ events.tool_requested(
25
+ VoiceManager.ToolRequest(
26
+ call_id=call_id, name="get_local_time", arguments="{}"
27
+ )
28
+ )
29
+
30
+
31
+ async def test_start_passes_tools_to_the_provider():
32
+ bench = Bench()
33
+ async with voice_host(bench) as (drv, _app):
34
+ rsp = await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
35
+ assert rsp["type"] == "accepted", rsp
36
+ assert bench.provider.calls[0] == ("start", OFFER, [], [TOOL])
37
+
38
+
39
+ async def test_malformed_tools_reject_invalid_message():
40
+ bench = Bench()
41
+ async with voice_host(bench) as (drv, _app):
42
+ for tools in (
43
+ "not-a-list",
44
+ [{"name": "", "description": "", "parameters": {}}],
45
+ [{"name": "a", "description": "d"}],
46
+ [TOOL, TOOL],
47
+ ):
48
+ assert_rejected(
49
+ await drv.command("voice/start", {"offer": OFFER, "tools": tools}),
50
+ "invalid-message",
51
+ )
52
+ assert bench.provider.calls == []
53
+
54
+
55
+ async def test_client_tool_call_projects_into_state():
56
+ bench = Bench()
57
+ async with voice_host(bench) as (drv, _app):
58
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
59
+ call_of(bench.events())
60
+ await drv.wait(
61
+ lambda s: (voice_of(s) or {}).get("toolCalls")
62
+ == [{"callId": "call-1", "name": "get_local_time", "arguments": "{}"}]
63
+ )
64
+ # the call surfaced to the client instead of the host handler
65
+ assert bench.tasks == []
66
+
67
+
68
+ async def test_tool_result_forwards_and_clears_the_pending_call():
69
+ bench = Bench()
70
+ async with voice_host(bench) as (drv, _app):
71
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
72
+ call_of(bench.events())
73
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
74
+ rsp = await drv.command(
75
+ "voice/temp-frontend-tool-result",
76
+ {"call_id": "call-1", "output": "12:30"},
77
+ )
78
+ assert rsp["type"] == "accepted", rsp
79
+ assert ("return_tool_result", "call-1", "12:30") in bench.provider.calls
80
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") == [])
81
+
82
+
83
+ async def test_unknown_call_id_rejects_unknown_tool_call():
84
+ bench = Bench()
85
+ async with voice_host(bench) as (drv, _app):
86
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
87
+ assert_rejected(
88
+ await drv.command(
89
+ "voice/temp-frontend-tool-result",
90
+ {"call_id": "call-9", "output": "x"},
91
+ ),
92
+ "unknown-tool-call",
93
+ )
94
+
95
+
96
+ async def test_tool_result_from_other_client_rejects_not_voice_owner():
97
+ bench = Bench()
98
+ async with voice_host(bench) as (drv, app):
99
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
100
+ call_of(bench.events())
101
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
102
+ rsp = await side_command(
103
+ app,
104
+ drv._client,
105
+ "voice/temp-frontend-tool-result",
106
+ {"call_id": "call-1", "output": "x"},
107
+ )
108
+ assert_rejected(rsp, "not-voice-owner")
109
+ assert ("return_tool_result", "call-1", "x") not in bench.provider.calls
@@ -11,7 +11,13 @@ from run_helpers import RunDriver, _statement
11
11
  from statewire import Statewire, command
12
12
  from statewire_helpers import apply_ops, post_frame, statewire_client, stream_of
13
13
 
14
- from harness_sdk import VoiceManager, end_voice, start_voice, voice_manager
14
+ from harness_sdk import (
15
+ VoiceManager,
16
+ end_voice,
17
+ start_voice,
18
+ temp_frontend_tool_result,
19
+ voice_manager,
20
+ )
15
21
 
16
22
  OFFER = "v=0 fake-offer"
17
23
  ANSWER = "v=0 fake-answer"
@@ -27,8 +33,8 @@ class FakeVoiceProvider:
27
33
  self.events: VoiceManager.Events | None = None
28
34
  self.closed = False
29
35
 
30
- async def start(self, offer, *, seed, events):
31
- self.calls.append(("start", offer, list(seed)))
36
+ async def start(self, offer, *, seed, tools, events):
37
+ self.calls.append(("start", offer, list(seed), list(tools)))
32
38
  if self.fail is not None:
33
39
  raise self.fail
34
40
  self.events = events
@@ -64,6 +70,8 @@ class Bench:
64
70
  commits: list[list[dict[str, Any]]] = field(default_factory=list)
65
71
  commit_failures: list[Exception] = field(default_factory=list)
66
72
  contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
73
+ tasks: list[Any] = field(default_factory=list)
74
+ task_handler: Any = None
67
75
 
68
76
  def events(self) -> VoiceManager.Events:
69
77
  assert self.provider.events is not None, "provider.start never ran"
@@ -108,6 +116,13 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
108
116
  del self._ephemeral[message_id]
109
117
  self._project_messages()
110
118
 
119
+ async def handle_voice_task(self, task, *, speak):
120
+ bench.tasks.append(task)
121
+ if bench.task_handler is not None:
122
+ return await bench.task_handler(task, speak)
123
+ request_id = getattr(task, "task_id", None) or task.call_id
124
+ return f"done:{request_id}"
125
+
111
126
  @command("voice/start")
112
127
  async def voice_start(self, params, *, ctx):
113
128
  return await start_voice(self, params, ctx=ctx)
@@ -116,6 +131,10 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
116
131
  async def voice_end(self, params=None, *, ctx):
117
132
  return await end_voice(self, ctx=ctx)
118
133
 
134
+ @command("voice/temp-frontend-tool-result")
135
+ async def voice_tool_result(self, params, *, ctx):
136
+ return await temp_frontend_tool_result(self, params, ctx=ctx)
137
+
119
138
  if not declare:
120
139
  return Host
121
140