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.
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/__init__.py +2 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/voice.py +225 -12
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_voice.py +39 -8
- harness_sdk_python-0.30.0/tests/test_voice_delegation.py +164 -0
- harness_sdk_python-0.30.0/tests/test_voice_frontend_tools.py +109 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/voice_helpers.py +22 -3
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/.gitignore +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/README.md +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.29.1 → harness_sdk_python-0.30.0}/tests/test_stopping_window.py +0 -0
|
@@ -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
|
|
11
|
-
most 128 messages, tail taken)
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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]",
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 == [
|
|
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
|
-
== [
|
|
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
|
|
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 == [
|
|
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
|
|
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
|
|
|
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
|