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.
Files changed (44) hide show
  1. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/voice.py +65 -20
  4. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_voice_frontend_tools.py +89 -2
  5. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/voice_helpers.py +5 -1
  6. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/.gitignore +0 -0
  7. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/README.md +0 -0
  8. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/examples/__init__.py +0 -0
  9. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/examples/runs_app.py +0 -0
  10. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/__init__.py +0 -0
  11. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/checkpoints.py +0 -0
  12. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/deepagents.py +0 -0
  13. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/src/harness_sdk/run_manager.py +0 -0
  16. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/run_helpers.py +0 -0
  17. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_admit_steer.py +0 -0
  18. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_applied_visibility.py +0 -0
  19. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_batches.py +0 -0
  20. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_branch_anchor.py +0 -0
  21. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_checkpoints.py +0 -0
  22. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_deepagents_messages.py +0 -0
  23. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_dispatching.py +0 -0
  24. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_durability.py +0 -0
  25. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_edit_dispatched.py +0 -0
  26. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_edit_reload.py +0 -0
  27. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_enqueue.py +0 -0
  28. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_input_required.py +0 -0
  30. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_settle.py +0 -0
  40. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_steer.py +0 -0
  41. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_stop_continue.py +0 -0
  42. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_stopping_window.py +0 -0
  43. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_voice.py +0 -0
  44. {harness_sdk_python-0.30.1 → harness_sdk_python-0.31.0}/tests/test_voice_delegation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.30.1
3
+ Version: 0.31.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.30.1"
3
+ version = "0.31.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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: set[str] = set()
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 = {tool["name"] for tool in 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._host.handle_voice_task(task, speak=self._speak)
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.toolCalls.value = [
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
- pending = self.toolCalls.value
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.toolCalls.value = [
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._host.handle_voice_task(request, speak=self._speak)
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"]``, and
3
- ``voice/temp-frontend-tool-result`` answers them."""
2
+ provider, matching calls project into ``state["voice"]["toolCalls"]``,
3
+ ``voice/temp-frontend-tool-result`` answers them, and the delegated-task
4
+ handler's ``call_frontend_tool`` rides the same projection."""
5
+
6
+ import asyncio
4
7
 
5
8
  from run_helpers import assert_rejected
6
9
  from voice_helpers import (
@@ -13,6 +16,13 @@ from voice_helpers import (
13
16
 
14
17
  from harness_sdk import VoiceManager
15
18
 
19
+
20
+ async def until(predicate, timeout: float = 5) -> None:
21
+ deadline = asyncio.get_running_loop().time() + timeout
22
+ while not predicate():
23
+ assert asyncio.get_running_loop().time() < deadline, "condition never held"
24
+ await asyncio.sleep(0.01)
25
+
16
26
  TOOL = {
17
27
  "name": "get_local_time",
18
28
  "description": "Read the user's local date and time.",
@@ -93,6 +103,83 @@ async def test_unknown_call_id_rejects_unknown_tool_call():
93
103
  )
94
104
 
95
105
 
106
+ async def test_executor_call_projects_and_the_result_resolves_the_await():
107
+ bench = Bench()
108
+ outputs: list[str] = []
109
+
110
+ async def handler(task, speak):
111
+ outputs.append(await bench.call_frontend_tool("get_local_time", "{}"))
112
+ return "wrapped it up"
113
+
114
+ bench.task_handler = handler
115
+ async with voice_host(bench) as (drv, _app):
116
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
117
+ bench.events().task_requested("d1", "what time is it locally?")
118
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
119
+ [call] = drv.replica["voice"]["toolCalls"]
120
+ assert call["name"] == "get_local_time"
121
+ assert call["arguments"] == "{}"
122
+ assert bench.tool_defs == [TOOL]
123
+ rsp = await drv.command(
124
+ "voice/temp-frontend-tool-result",
125
+ {"call_id": call["callId"], "output": "12:30"},
126
+ )
127
+ assert rsp["type"] == "accepted", rsp
128
+ await until(
129
+ lambda: ("append_context", "wrapped it up", "speakable")
130
+ in bench.provider.calls
131
+ )
132
+ assert outputs == ["12:30"]
133
+ assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
134
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") == [])
135
+
136
+
137
+ async def test_executor_call_with_undeclared_tool_raises_in_the_handler():
138
+ bench = Bench()
139
+
140
+ async def handler(task, speak):
141
+ return await bench.call_frontend_tool("open_pod_bay_doors", "{}")
142
+
143
+ bench.task_handler = handler
144
+ async with voice_host(bench) as (drv, _app):
145
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
146
+ bench.events().task_requested("d1", "open up")
147
+ await until(
148
+ lambda: any(
149
+ c[0] == "append_context" and c[2] == "commentary"
150
+ for c in bench.provider.calls
151
+ )
152
+ )
153
+ [(_, text, _channel)] = [
154
+ c for c in bench.provider.calls if c[0] == "append_context"
155
+ ]
156
+ assert "'open_pod_bay_doors' is not a client-declared frontend tool" in text
157
+ assert drv.replica["voice"]["toolCalls"] == []
158
+
159
+
160
+ async def test_session_end_fails_a_pending_executor_call():
161
+ bench = Bench()
162
+ failures: list[Exception] = []
163
+
164
+ async def handler(task, speak):
165
+ try:
166
+ return await bench.call_frontend_tool("get_local_time", "{}")
167
+ except Exception as exc:
168
+ failures.append(exc)
169
+ raise
170
+
171
+ bench.task_handler = handler
172
+ async with voice_host(bench) as (drv, _app):
173
+ await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
174
+ bench.events().task_requested("d1", "what time is it?")
175
+ await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
176
+ rsp = await drv.command("voice/end")
177
+ assert rsp["type"] == "accepted", rsp
178
+ await until(lambda: failures != [])
179
+ assert isinstance(failures[0], RuntimeError)
180
+ assert all(c[0] != "return_tool_result" for c in bench.provider.calls)
181
+
182
+
96
183
  async def test_tool_result_from_other_client_rejects_not_voice_owner():
97
184
  bench = Bench()
98
185
  async with voice_host(bench) as (drv, app):
@@ -72,6 +72,8 @@ class Bench:
72
72
  contexts: list[VoiceManager.SessionContext] = field(default_factory=list)
73
73
  tasks: list[Any] = field(default_factory=list)
74
74
  task_handler: Any = None
75
+ call_frontend_tool: Any = None
76
+ tool_defs: list[Any] = field(default_factory=list)
75
77
 
76
78
  def events(self) -> VoiceManager.Events:
77
79
  assert self.provider.events is not None, "provider.start never ran"
@@ -116,8 +118,10 @@ def make_voice_host(bench: Bench, *, declare: bool = True):
116
118
  del self._ephemeral[message_id]
117
119
  self._project_messages()
118
120
 
119
- async def handle_voice_task(self, task, *, speak):
121
+ async def handle_voice_task(self, task, *, speak, call_frontend_tool, tools):
120
122
  bench.tasks.append(task)
123
+ bench.call_frontend_tool = call_frontend_tool
124
+ bench.tool_defs = tools
121
125
  if bench.task_handler is not None:
122
126
  return await bench.task_handler(task, speak)
123
127
  request_id = getattr(task, "task_id", None) or task.call_id