harness-sdk-python 0.29.2__py3-none-any.whl → 0.30.1__py3-none-any.whl

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/__init__.py CHANGED
@@ -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
  ]
harness_sdk/voice.py CHANGED
@@ -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, activeTask}`` — 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
@@ -21,6 +26,16 @@ same id, a failed commit keeps its turns for the next flush; session end
21
26
  flushes the remaining done turns, then commits in-progress turns with
22
27
  their partial text; in-progress turns with no text yet are dropped.
23
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). The running unit
37
+ projects as ``activeTask`` = ``{taskId, text}``, ``None`` at rest.
38
+
24
39
  Rejections: ``voice-active`` (a session is already live), ``no-voice``
25
40
  (no voice manager declared, or ``voice/end`` with no session),
26
41
  ``not-voice-owner`` (``voice/end`` from another client),
@@ -30,9 +45,10 @@ Rejections: ``voice-active`` (a session is already live), ``no-voice``
30
45
  import asyncio
31
46
  import functools
32
47
  import inspect
48
+ import json
33
49
  import logging
34
50
  from dataclasses import dataclass
35
- from typing import Any, Callable, Literal, Protocol, TypedDict
51
+ from typing import Any, Awaitable, Callable, Literal, Protocol, TypedDict
36
52
  from uuid import uuid4
37
53
 
38
54
  from pinned.trays import Tray, TrayDescriptor, TrayMapNamespace
@@ -49,6 +65,30 @@ def _reject(reason: str, message: str) -> StatewireReject:
49
65
  return StatewireReject(message, payload={"reason": reason})
50
66
 
51
67
 
68
+ def _validated_tools(tools: Any) -> "list[VoiceManager.ToolDef]":
69
+ if tools is None:
70
+ return []
71
+ if not isinstance(tools, list):
72
+ raise _reject("invalid-message", "tools must be a list")
73
+ names: set[str] = set()
74
+ for tool in tools:
75
+ if (
76
+ not isinstance(tool, dict)
77
+ or not isinstance(tool.get("name"), str)
78
+ or tool["name"] == ""
79
+ or not isinstance(tool.get("description"), str)
80
+ or not isinstance(tool.get("parameters"), dict)
81
+ ):
82
+ raise _reject(
83
+ "invalid-message",
84
+ f"tools entries must be {{name, description, parameters}}, got {tool!r}",
85
+ )
86
+ if tool["name"] in names:
87
+ raise _reject("invalid-message", f"duplicate tool name {tool['name']!r}")
88
+ names.add(tool["name"])
89
+ return tools
90
+
91
+
52
92
  def _capped_seed(seed: Any) -> "list[VoiceManager.SeedMessage]":
53
93
  if not isinstance(seed, list):
54
94
  raise TypeError(f"get_voice_seed must return a list, got {seed!r}")
@@ -71,11 +111,15 @@ class VoiceManager(Tray):
71
111
 
72
112
  clientId = observable(None)
73
113
  status = observable("connecting")
114
+ toolCalls = observable([])
115
+ activeTask = observable(None)
74
116
 
75
117
  state = observable(
76
118
  {
77
119
  "clientId": clientId,
78
120
  "status": status,
121
+ "toolCalls": toolCalls,
122
+ "activeTask": activeTask,
79
123
  }
80
124
  )
81
125
 
@@ -86,6 +130,11 @@ class VoiceManager(Tray):
86
130
  self._flushing = asyncio.Lock()
87
131
  self._ended = False
88
132
  self._watcher: "asyncio.Task[Any] | None" = None
133
+ self._handoff_pending: list[dict[str, str]] = []
134
+ self._seen_requests: set[str] = set()
135
+ self._delegating = asyncio.Lock()
136
+ self._tasks_run = 0
137
+ self._client_tools: set[str] = set()
89
138
 
90
139
  def items(self):
91
140
  yield from ()
@@ -96,12 +145,41 @@ class VoiceManager(Tray):
96
145
  role: Literal["user", "assistant", "system"]
97
146
  text: str
98
147
 
148
+ class ToolDef(TypedDict):
149
+ """One frontend function tool the client declares at ``voice/start``."""
150
+
151
+ name: str
152
+ description: str
153
+ parameters: dict[str, Any]
154
+
99
155
  @dataclass(frozen=True)
100
156
  class ToolRequest:
101
157
  call_id: str
102
158
  name: str
103
159
  arguments: str
104
160
 
161
+ @dataclass(frozen=True)
162
+ class TaskRequest:
163
+ """One delegated free-text work unit plus its transcript delta."""
164
+
165
+ task_id: str
166
+ text: str
167
+ transcript: str
168
+ follow_up: bool
169
+
170
+ def handoff(self) -> str:
171
+ """Render the task and its transcript delta as one delegate-facing prompt."""
172
+ window = (
173
+ "the voice conversation since the previous delegated task"
174
+ if self.follow_up
175
+ else "the voice conversation so far"
176
+ )
177
+ transcript = self.transcript or "(No new conversation was captured.)"
178
+ return (
179
+ f"This is the transcript of {window}:\n\n{transcript}\n\n"
180
+ f"The voice model delegated this task:\n{self.text}"
181
+ )
182
+
105
183
  @dataclass(frozen=True)
106
184
  class SessionContext:
107
185
  """The provider factory's per-session context."""
@@ -119,6 +197,8 @@ class VoiceManager(Tray):
119
197
 
120
198
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None: ...
121
199
 
200
+ def task_requested(self, task_id: str, text: str) -> None: ...
201
+
122
202
  def rollover_approaching(self, rollover_id: str, deadline: float) -> None: ...
123
203
 
124
204
  def closed(self, reason: str) -> None: ...
@@ -142,18 +222,29 @@ class VoiceManager(Tray):
142
222
  """Remove an in-flight turn from the ``state["messages"]`` projection."""
143
223
  ...
144
224
 
225
+ async def handle_voice_task(
226
+ self,
227
+ task: "VoiceManager.TaskRequest | VoiceManager.ToolRequest",
228
+ *,
229
+ speak: Callable[[str], Awaitable[None]],
230
+ ) -> str:
231
+ """Fulfill one delegated work unit; ``speak`` streams interim spoken updates and the return value is the result."""
232
+ ...
233
+
145
234
  # ─── Session lifecycle ──────────────────────────────────
146
235
 
147
236
  async def _begin(
148
- self, offer: str, seed: "list[VoiceManager.SeedMessage]", caller: Any,
237
+ self, offer: str, seed: "list[VoiceManager.SeedMessage]",
238
+ tools: "list[VoiceManager.ToolDef]", caller: Any,
149
239
  applied: Callable[[], None],
150
240
  ) -> Any:
151
241
  self.clientId.value = caller.client_id
242
+ self._client_tools = {tool["name"] for tool in tools}
152
243
  try:
153
244
  provider = await self.handler(
154
245
  VoiceManager.SessionContext(client_id=caller.client_id)
155
246
  )
156
- answer = await provider.start(offer, seed=seed, events=self)
247
+ answer = await provider.start(offer, seed=seed, tools=tools, events=self)
157
248
  if not isinstance(answer, str) or answer == "":
158
249
  raise TypeError(f"provider.start must return an SDP answer, got {answer!r}")
159
250
  except Exception as exc:
@@ -258,11 +349,107 @@ class VoiceManager(Tray):
258
349
  turn = self._turn(turn_id)
259
350
  turn["text"] = text
260
351
  turn["done"] = True
352
+ self._handoff_pending.append({"role": turn["role"], "text": text})
261
353
  self._render(turn)
262
354
  self._spawn(self._flush())
263
355
 
356
+ def _claim(self, request_id: str) -> bool:
357
+ if request_id in self._seen_requests:
358
+ return False
359
+ self._seen_requests.add(request_id)
360
+ return True
361
+
362
+ async def _append(
363
+ self, text: str, *, channel: Literal["speakable", "commentary"]
364
+ ) -> None:
365
+ provider = self._provider
366
+ if provider is not None:
367
+ await provider.append_context(text, channel=channel)
368
+
369
+ async def _speak(self, text: str) -> None:
370
+ await self._append(text, channel="speakable")
371
+
372
+ def task_requested(self, task_id: str, text: str) -> None:
373
+ if self._ended or not self._claim(task_id):
374
+ return
375
+ self._spawn(self._run_delegated_task(task_id, text))
376
+
377
+ async def _run_delegated_task(self, task_id: str, text: str) -> None:
378
+ # the lock orders transcript drains and keeps one delegation in flight
379
+ async with self._delegating:
380
+ pending, self._handoff_pending = self._handoff_pending, []
381
+ task = VoiceManager.TaskRequest(
382
+ task_id=task_id,
383
+ text=text,
384
+ transcript="\n".join(f"[{t['role']}] {t['text']}" for t in pending),
385
+ follow_up=self._tasks_run > 0,
386
+ )
387
+ self._tasks_run += 1
388
+ self.activeTask.value = {"taskId": task_id, "text": text}
389
+ try:
390
+ answer = await self._host.handle_voice_task(task, speak=self._speak)
391
+ if not isinstance(answer, str) or answer == "":
392
+ raise TypeError(
393
+ f"handle_voice_task must return the result text, got {answer!r}"
394
+ )
395
+ except Exception as exc:
396
+ logger.exception("voice session %r task %r failed", self.id, task_id)
397
+ await self._append(
398
+ f"The delegated work could not be completed: {exc}",
399
+ channel="commentary",
400
+ )
401
+ return
402
+ finally:
403
+ self.activeTask.value = None
404
+ await self._append(answer, channel="speakable")
405
+
264
406
  def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
265
- logger.info("voice session %r tool request ignored: %r", self.id, request)
407
+ if self._ended or not self._claim(request.call_id):
408
+ 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
+ ]
418
+ return
419
+ self._spawn(self._run_tool_call(request))
420
+
421
+ 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):
424
+ raise _reject(
425
+ "unknown-tool-call", f"no pending frontend tool call {call_id!r}"
426
+ )
427
+ provider = self._provider
428
+ if provider is None:
429
+ raise _reject("no-voice", "the voice session is not live")
430
+ await provider.return_tool_result(call_id, output)
431
+ self.toolCalls.value = [
432
+ call for call in pending if call["callId"] != call_id
433
+ ]
434
+
435
+ async def _run_tool_call(self, request: "VoiceManager.ToolRequest") -> None:
436
+ self.activeTask.value = {"taskId": request.call_id, "text": request.name}
437
+ try:
438
+ output = await self._host.handle_voice_task(request, speak=self._speak)
439
+ if not isinstance(output, str):
440
+ raise TypeError(
441
+ f"handle_voice_task must return the result text, got {output!r}"
442
+ )
443
+ except Exception as exc:
444
+ logger.exception(
445
+ "voice session %r tool call %r failed", self.id, request.call_id
446
+ )
447
+ output = json.dumps({"error": f"{type(exc).__name__}: {exc}"})
448
+ finally:
449
+ self.activeTask.value = None
450
+ provider = self._provider
451
+ if provider is not None:
452
+ await provider.return_tool_result(request.call_id, output)
266
453
 
267
454
  def rollover_approaching(self, rollover_id: str, deadline: float) -> None:
268
455
  logger.info(
@@ -282,6 +469,7 @@ class VoiceProvider(Protocol):
282
469
  async def start(
283
470
  self, offer: str, *,
284
471
  seed: "list[VoiceManager.SeedMessage]",
472
+ tools: "list[VoiceManager.ToolDef]",
285
473
  events: "VoiceManager.Events",
286
474
  ) -> str:
287
475
  """Open the session from an SDP offer and return the SDP answer; events flow into the sink."""
@@ -311,6 +499,7 @@ class VoiceManagerNamespace(TrayMapNamespace):
311
499
  "commit_voice_turns",
312
500
  "upsert_ephemeral_message",
313
501
  "drop_ephemeral_message",
502
+ "handle_voice_task",
314
503
  ):
315
504
  if not callable(getattr(api, name, None)):
316
505
  raise TypeError(
@@ -343,6 +532,7 @@ class VoiceManagerNamespace(TrayMapNamespace):
343
532
  offer = params.get("offer")
344
533
  if not isinstance(offer, str) or offer == "":
345
534
  raise _reject("invalid-message", "offer must be a non-empty SDP string")
535
+ tools = _validated_tools(params.get("tools"))
346
536
  if self._starting or self.live() is not None:
347
537
  raise _reject("voice-active", "a voice session is already live")
348
538
  self._starting = True
@@ -350,19 +540,38 @@ class VoiceManagerNamespace(TrayMapNamespace):
350
540
  seed = _capped_seed(await self._api.get_voice_seed())
351
541
  tray = await self.get_or_create(uuid4().hex)
352
542
  assert isinstance(tray, VoiceManager)
353
- return await tray._begin(offer, seed, ctx.caller, ctx.applied)
543
+ return await tray._begin(offer, seed, tools, ctx.caller, ctx.applied)
354
544
  finally:
355
545
  self._starting = False
356
546
 
357
- async def end(self, *, ctx: Any) -> None:
547
+ def _owned_session(self, ctx: Any, command: str) -> VoiceManager:
358
548
  session = self.live()
359
549
  if session is None:
360
550
  raise _reject("no-voice", "no voice session is live")
361
551
  if session.clientId.value != ctx.caller.client_id:
362
- raise _reject("not-voice-owner", "voice/end must come from the session owner")
552
+ raise _reject(
553
+ "not-voice-owner", f"{command} must come from the session owner"
554
+ )
555
+ return session
556
+
557
+ async def end(self, *, ctx: Any) -> None:
558
+ session = self._owned_session(ctx, "voice/end")
363
559
  await session._end("client")
364
560
  ctx.applied()
365
561
 
562
+ async def temp_frontend_tool_result(self, params: Any, *, ctx: Any) -> None:
563
+ session = self._owned_session(ctx, "voice/temp-frontend-tool-result")
564
+ if not isinstance(params, dict):
565
+ raise _reject("invalid-message", "params must be an object")
566
+ call_id = params.get("call_id")
567
+ output = params.get("output")
568
+ if not isinstance(call_id, str) or call_id == "" or not isinstance(output, str):
569
+ raise _reject(
570
+ "invalid-message", "call_id and output must be non-empty strings"
571
+ )
572
+ await session._answer_client_call(call_id, output)
573
+ ctx.applied()
574
+
366
575
 
367
576
  class VoiceManagerDescriptor(TrayDescriptor):
368
577
  namespace_cls = VoiceManagerNamespace
@@ -419,3 +628,11 @@ async def end_voice(host: Any, *, ctx: Any) -> None:
419
628
  if namespace is None:
420
629
  raise _reject("no-voice", "this host declares no voice manager")
421
630
  return await namespace.end(ctx=ctx)
631
+
632
+
633
+ async def temp_frontend_tool_result(host: Any, params: Any, *, ctx: Any) -> None:
634
+ """The ``voice/temp-frontend-tool-result`` command body: routes to the host's declared voice manager."""
635
+ namespace = _voice_of(host)
636
+ if namespace is None:
637
+ raise _reject("no-voice", "this host declares no voice manager")
638
+ return await namespace.temp_frontend_tool_result(params, ctx=ctx)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.29.2
3
+ Version: 0.30.1
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,10 +1,10 @@
1
- harness_sdk/__init__.py,sha256=N9gOUKqe0_eM7cIA9sfA_lH1FBw1MN8U-zerwGAPVHE,1094
1
+ harness_sdk/__init__.py,sha256=EO0rTCg1NmWPNlnip81KjkvKYoc0dGLULKv2ton3tG8,1158
2
2
  harness_sdk/checkpoints.py,sha256=L02l1IyLDj2b-GD7_HGrck7uenALeI4kg8yJSzoNVmc,25999
3
3
  harness_sdk/deepagents.py,sha256=cMipZ9ndZ3zUR3-VqOMf5CiPVMmym74ytIeguQAjYZo,12280
4
4
  harness_sdk/fenced_postgres.py,sha256=hbbrMR1WaqrHTNgd4wFrXfRrzQFGnyim-c8nZXgwWp4,13235
5
5
  harness_sdk/linear_thread.py,sha256=laDRXKLFsMlyWS4snqNAM6t38wO26gevLWXRNivhHSM,1810
6
6
  harness_sdk/run_manager.py,sha256=CbY_4dpFeqGx3kJNI1ffsu3uIvzVc-Tc1UB6Nr9SiHY,77654
7
- harness_sdk/voice.py,sha256=oVmfxltjyV9ylATIpAszaM6MOS9TC3Haksgf6I7c4v8,15863
8
- harness_sdk_python-0.29.2.dist-info/METADATA,sha256=-4c6JdBIpkP6td3WlIcpnbUOX9ntoTM5dG6vcwoD55k,2200
9
- harness_sdk_python-0.29.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
- harness_sdk_python-0.29.2.dist-info/RECORD,,
7
+ harness_sdk/voice.py,sha256=hoGCrhyj5t7R4NJ4bgQTIrJ8IdnZegNzTxBq4QWr94o,25060
8
+ harness_sdk_python-0.30.1.dist-info/METADATA,sha256=T4aKBeT5B798dgREHJBSUNmcowIjNCgEnp5vtVujcHc,2200
9
+ harness_sdk_python-0.30.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ harness_sdk_python-0.30.1.dist-info/RECORD,,