harness-sdk-python 0.30.1__tar.gz → 0.31.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/voice.py +65 -20
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_voice_frontend_tools.py +89 -2
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/voice_helpers.py +5 -1
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/.gitignore +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/README.md +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_voice.py +0 -0
- {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_voice_delegation.py +0 -0
|
@@ -34,7 +34,11 @@ 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
36
|
the model as commentary (task) or an error output (call). The running unit
|
|
37
|
-
projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest.
|
|
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.
|
|
38
42
|
|
|
39
43
|
Rejections: ``voice-active`` (a session is already live), ``no-voice``
|
|
40
44
|
(no voice manager declared, or ``voice/end`` with no session),
|
|
@@ -134,7 +138,8 @@ class VoiceManager(Tray):
|
|
|
134
138
|
self._seen_requests: set[str] = set()
|
|
135
139
|
self._delegating = asyncio.Lock()
|
|
136
140
|
self._tasks_run = 0
|
|
137
|
-
self._client_tools:
|
|
141
|
+
self._client_tools: "list[VoiceManager.ToolDef]" = []
|
|
142
|
+
self._executor_waits: dict[str, asyncio.Future[str]] = {}
|
|
138
143
|
|
|
139
144
|
def items(self):
|
|
140
145
|
yield from ()
|
|
@@ -227,8 +232,10 @@ class VoiceManager(Tray):
|
|
|
227
232
|
task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest",
|
|
228
233
|
*,
|
|
229
234
|
speak: Callable[[str], Awaitable[None]],
|
|
235
|
+
call_frontend_tool: Callable[[str, str], Awaitable[str]],
|
|
236
|
+
tools: "list[VoiceManager.ToolDef]",
|
|
230
237
|
) -> str:
|
|
231
|
-
"""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."""
|
|
232
239
|
...
|
|
233
240
|
|
|
234
241
|
# ─── Session lifecycle ──────────────────────────────────
|
|
@@ -239,7 +246,7 @@ class VoiceManager(Tray):
|
|
|
239
246
|
applied: Callable[[], None],
|
|
240
247
|
) -> Any:
|
|
241
248
|
self.clientId.value = caller.client_id
|
|
242
|
-
self._client_tools =
|
|
249
|
+
self._client_tools = tools
|
|
243
250
|
try:
|
|
244
251
|
provider = await self.handler(
|
|
245
252
|
VoiceManager.SessionContext(client_id=caller.client_id)
|
|
@@ -289,6 +296,11 @@ class VoiceManager(Tray):
|
|
|
289
296
|
turns, self._turns = self._turns, []
|
|
290
297
|
for turn in turns:
|
|
291
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()
|
|
292
304
|
self.status.value = "closed"
|
|
293
305
|
self.delete()
|
|
294
306
|
|
|
@@ -387,7 +399,7 @@ class VoiceManager(Tray):
|
|
|
387
399
|
self._tasks_run += 1
|
|
388
400
|
self.activeTask.value = {"taskId": task_id, "text": text}
|
|
389
401
|
try:
|
|
390
|
-
answer = await self.
|
|
402
|
+
answer = await self._delegate(task)
|
|
391
403
|
if not isinstance(answer, str) or answer == "":
|
|
392
404
|
raise TypeError(
|
|
393
405
|
f"handle_voice_task must return the result text, got {answer!r}"
|
|
@@ -403,39 +415,72 @@ class VoiceManager(Tray):
|
|
|
403
415
|
self.activeTask.value = None
|
|
404
416
|
await self._append(answer, channel="speakable")
|
|
405
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
|
+
|
|
406
441
|
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
|
|
407
442
|
if self._ended or not self._claim(request.call_id):
|
|
408
443
|
return
|
|
409
|
-
if request.name in self._client_tools:
|
|
410
|
-
self.
|
|
411
|
-
*self.toolCalls.value,
|
|
412
|
-
{
|
|
413
|
-
"callId": request.call_id,
|
|
414
|
-
"name": request.name,
|
|
415
|
-
"arguments": request.arguments,
|
|
416
|
-
},
|
|
417
|
-
]
|
|
444
|
+
if any(tool["name"] == request.name for tool in self._client_tools):
|
|
445
|
+
self._project_call(request.call_id, request.name, request.arguments)
|
|
418
446
|
return
|
|
419
447
|
self._spawn(self._run_tool_call(request))
|
|
420
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
|
+
|
|
421
464
|
async def _answer_client_call(self, call_id: str, output: str) -> None:
|
|
422
|
-
|
|
423
|
-
if not any(call["callId"] == call_id for call in pending):
|
|
465
|
+
if not any(call["callId"] == call_id for call in self.toolCalls.value):
|
|
424
466
|
raise _reject(
|
|
425
467
|
"unknown-tool-call", f"no pending frontend tool call {call_id!r}"
|
|
426
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
|
|
427
474
|
provider = self._provider
|
|
428
475
|
if provider is None:
|
|
429
476
|
raise _reject("no-voice", "the voice session is not live")
|
|
430
477
|
await provider.return_tool_result(call_id, output)
|
|
431
|
-
self.
|
|
432
|
-
call for call in pending if call["callId"] != call_id
|
|
433
|
-
]
|
|
478
|
+
self._clear_call(call_id)
|
|
434
479
|
|
|
435
480
|
async def _run_tool_call(self, request: "VoiceManager.ToolRequest") -> None:
|
|
436
481
|
self.activeTask.value = {"taskId": request.call_id, "text": request.name}
|
|
437
482
|
try:
|
|
438
|
-
output = await self.
|
|
483
|
+
output = await self._delegate(request)
|
|
439
484
|
if not isinstance(output, str):
|
|
440
485
|
raise TypeError(
|
|
441
486
|
f"handle_voice_task must return the result text, got {output!r}"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""Frontend function tools contract: ``voice/start {tools}`` reaches the
|
|
2
|
-
provider, matching calls project into ``state["voice"]["toolCalls"]``,
|
|
3
|
-
``voice/temp-frontend-tool-result`` answers them
|
|
2
|
+
provider, matching calls project into ``state["voice"]["toolCalls"]``,
|
|
3
|
+
``voice/temp-frontend-tool-result`` answers them, and the delegated-task
|
|
4
|
+
handler's ``call_frontend_tool`` rides the same projection."""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
4
7
|
|
|
5
8
|
from run_helpers import assert_rejected
|
|
6
9
|
from voice_helpers import (
|
|
@@ -13,6 +16,13 @@ from voice_helpers import (
|
|
|
13
16
|
|
|
14
17
|
from harness_sdk import VoiceManager
|
|
15
18
|
|
|
19
|
+
|
|
20
|
+
async def until(predicate, timeout: float = 5) -> None:
|
|
21
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
22
|
+
while not predicate():
|
|
23
|
+
assert asyncio.get_running_loop().time() < deadline, "condition never held"
|
|
24
|
+
await asyncio.sleep(0.01)
|
|
25
|
+
|
|
16
26
|
TOOL = {
|
|
17
27
|
"name": "get_local_time",
|
|
18
28
|
"description": "Read the user's local date and time.",
|
|
@@ -93,6 +103,83 @@ async def test_unknown_call_id_rejects_unknown_tool_call():
|
|
|
93
103
|
)
|
|
94
104
|
|
|
95
105
|
|
|
106
|
+
async def test_executor_call_projects_and_the_result_resolves_the_await():
|
|
107
|
+
bench = Bench()
|
|
108
|
+
outputs: list[str] = []
|
|
109
|
+
|
|
110
|
+
async def handler(task, speak):
|
|
111
|
+
outputs.append(await bench.call_frontend_tool("get_local_time", "{}"))
|
|
112
|
+
return "wrapped it up"
|
|
113
|
+
|
|
114
|
+
bench.task_handler = handler
|
|
115
|
+
async with voice_host(bench) as (drv, _app):
|
|
116
|
+
await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
|
|
117
|
+
bench.events().task_requested("d1", "what time is it locally?")
|
|
118
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
119
|
+
[call] = drv.replica["voice"]["toolCalls"]
|
|
120
|
+
assert call["name"] == "get_local_time"
|
|
121
|
+
assert call["arguments"] == "{}"
|
|
122
|
+
assert bench.tool_defs == [TOOL]
|
|
123
|
+
rsp = await drv.command(
|
|
124
|
+
"voice/temp-frontend-tool-result",
|
|
125
|
+
{"call_id": call["callId"], "output": "12:30"},
|
|
126
|
+
)
|
|
127
|
+
assert rsp["type"] == "accepted", rsp
|
|
128
|
+
await until(
|
|
129
|
+
lambda: ("append_context", "wrapped it up", "speakable")
|
|
130
|
+
in bench.provider.calls
|
|
131
|
+
)
|
|
132
|
+
assert outputs == ["12:30"]
|
|
133
|
+
assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
|
|
134
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") == [])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def test_executor_call_with_undeclared_tool_raises_in_the_handler():
|
|
138
|
+
bench = Bench()
|
|
139
|
+
|
|
140
|
+
async def handler(task, speak):
|
|
141
|
+
return await bench.call_frontend_tool("open_pod_bay_doors", "{}")
|
|
142
|
+
|
|
143
|
+
bench.task_handler = handler
|
|
144
|
+
async with voice_host(bench) as (drv, _app):
|
|
145
|
+
await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
|
|
146
|
+
bench.events().task_requested("d1", "open up")
|
|
147
|
+
await until(
|
|
148
|
+
lambda: any(
|
|
149
|
+
c[0] == "append_context" and c[2] == "commentary"
|
|
150
|
+
for c in bench.provider.calls
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
[(_, text, _channel)] = [
|
|
154
|
+
c for c in bench.provider.calls if c[0] == "append_context"
|
|
155
|
+
]
|
|
156
|
+
assert "'open_pod_bay_doors' is not a client-declared frontend tool" in text
|
|
157
|
+
assert drv.replica["voice"]["toolCalls"] == []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_session_end_fails_a_pending_executor_call():
|
|
161
|
+
bench = Bench()
|
|
162
|
+
failures: list[Exception] = []
|
|
163
|
+
|
|
164
|
+
async def handler(task, speak):
|
|
165
|
+
try:
|
|
166
|
+
return await bench.call_frontend_tool("get_local_time", "{}")
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
failures.append(exc)
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
bench.task_handler = handler
|
|
172
|
+
async with voice_host(bench) as (drv, _app):
|
|
173
|
+
await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
|
|
174
|
+
bench.events().task_requested("d1", "what time is it?")
|
|
175
|
+
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
176
|
+
rsp = await drv.command("voice/end")
|
|
177
|
+
assert rsp["type"] == "accepted", rsp
|
|
178
|
+
await until(lambda: failures != [])
|
|
179
|
+
assert isinstance(failures[0], RuntimeError)
|
|
180
|
+
assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
|
|
181
|
+
|
|
182
|
+
|
|
96
183
|
async def test_tool_result_from_other_client_rejects_not_voice_owner():
|
|
97
184
|
bench = Bench()
|
|
98
185
|
async with voice_host(bench) as (drv, app):
|
|
@@ -72,6 +72,8 @@ class Bench:
|
|
|
72
72
|
contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
|
|
73
73
|
tasks: list[Any] = field(default_factory=list)
|
|
74
74
|
task_handler: Any = None
|
|
75
|
+
call_frontend_tool: Any = None
|
|
76
|
+
tool_defs: list[Any] = field(default_factory=list)
|
|
75
77
|
|
|
76
78
|
def events(self) -> VoiceManager.Events:
|
|
77
79
|
assert self.provider.events is not None, "provider.start never ran"
|
|
@@ -116,8 +118,10 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
|
|
|
116
118
|
del self._ephemeral[message_id]
|
|
117
119
|
self._project_messages()
|
|
118
120
|
|
|
119
|
-
async def handle_voice_task(self, task, *, speak):
|
|
121
|
+
async def handle_voice_task(self, task, *, speak, call_frontend_tool, tools):
|
|
120
122
|
bench.tasks.append(task)
|
|
123
|
+
bench.call_frontend_tool = call_frontend_tool
|
|
124
|
+
bench.tool_defs = tools
|
|
121
125
|
if bench.task_handler is not None:
|
|
122
126
|
return await bench.task_handler(task, speak)
|
|
123
127
|
request_id = getattr(task, "task_id", None) or task.call_id
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|