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 +2 -0
- harness_sdk/voice.py +228 -11
- {harness_sdk_python-0.29.2.dist-info → harness_sdk_python-0.30.1.dist-info}/METADATA +1 -1
- {harness_sdk_python-0.29.2.dist-info → harness_sdk_python-0.30.1.dist-info}/RECORD +5 -5
- {harness_sdk_python-0.29.2.dist-info → harness_sdk_python-0.30.1.dist-info}/WHEEL +0 -0
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
|
|
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, 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]",
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,10 +1,10 @@
|
|
|
1
|
-
harness_sdk/__init__.py,sha256=
|
|
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=
|
|
8
|
-
harness_sdk_python-0.
|
|
9
|
-
harness_sdk_python-0.
|
|
10
|
-
harness_sdk_python-0.
|
|
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,,
|
|
File without changes
|