harness-sdk-python 0.31.2__tar.gz → 0.32.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.31.2 → harness_sdk_python-0.32.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/voice.py +25 -20
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/run_helpers.py +15 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_voice_frontend_tools.py +40 -22
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/.gitignore +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/README.md +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_voice.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/voice_helpers.py +0 -0
|
@@ -7,9 +7,10 @@ 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
|
|
11
|
-
transcript (at most 128 messages, tail taken), hands the
|
|
12
|
-
function tool definitions
|
|
10
|
+
``voice/start {offer}`` seeds the provider from the thread
|
|
11
|
+
transcript (at most 128 messages, tail taken), hands the caller's frontend
|
|
12
|
+
function tool definitions (the ``tools`` map the client's synced model
|
|
13
|
+
context stores at ``ctx.caller.context``) to the provider, and projects ``state["voice"] =
|
|
13
14
|
{clientId, status, toolCalls, activeTask}`` — the SDP answer rides only the command's
|
|
14
15
|
result payload ``{answer}``. A named call matching a client-declared tool
|
|
15
16
|
projects into ``toolCalls`` as ``{callId, name, arguments}``; the owning
|
|
@@ -74,28 +75,32 @@ def _reject(reason: str, message: str) -> StatewireReject:
|
|
|
74
75
|
return StatewireReject(message, payload={"reason": reason})
|
|
75
76
|
|
|
76
77
|
|
|
77
|
-
def
|
|
78
|
+
def _context_tools(context: Any) -> "list[VoiceManager.ToolDef]":
|
|
79
|
+
tools = context.get("tools") if isinstance(context, dict) else None
|
|
78
80
|
if tools is None:
|
|
79
81
|
return []
|
|
80
|
-
if not isinstance(tools,
|
|
81
|
-
raise _reject("invalid-message", "tools must be a
|
|
82
|
-
|
|
83
|
-
for
|
|
82
|
+
if not isinstance(tools, dict):
|
|
83
|
+
raise _reject("invalid-message", "context tools must be a name-keyed object")
|
|
84
|
+
defs: "list[VoiceManager.ToolDef]" = []
|
|
85
|
+
for name, spec in tools.items():
|
|
84
86
|
if (
|
|
85
|
-
|
|
86
|
-
or not isinstance(
|
|
87
|
-
or
|
|
88
|
-
or not isinstance(
|
|
89
|
-
or not isinstance(tool.get("parameters"), dict)
|
|
87
|
+
name == ""
|
|
88
|
+
or not isinstance(spec, dict)
|
|
89
|
+
or not isinstance(spec.get("description", ""), str)
|
|
90
|
+
or not isinstance(spec.get("parameters"), dict)
|
|
90
91
|
):
|
|
91
92
|
raise _reject(
|
|
92
93
|
"invalid-message",
|
|
93
|
-
f"tools entries must be {{
|
|
94
|
+
f"context tools entries must be name -> {{description?, parameters}}, got {name!r}: {spec!r}",
|
|
94
95
|
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
defs.append(
|
|
97
|
+
{
|
|
98
|
+
"name": name,
|
|
99
|
+
"description": spec.get("description", ""),
|
|
100
|
+
"parameters": spec["parameters"],
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return defs
|
|
99
104
|
|
|
100
105
|
|
|
101
106
|
def _capped_seed(seed: Any) -> "list[VoiceManager.SeedMessage]":
|
|
@@ -156,7 +161,7 @@ class VoiceManager(Tray):
|
|
|
156
161
|
text: str
|
|
157
162
|
|
|
158
163
|
class ToolDef(TypedDict):
|
|
159
|
-
"""One frontend function tool the
|
|
164
|
+
"""One frontend function tool from the caller's synced model context."""
|
|
160
165
|
|
|
161
166
|
name: str
|
|
162
167
|
description: str
|
|
@@ -593,7 +598,7 @@ class VoiceManagerNamespace(TrayMapNamespace):
|
|
|
593
598
|
offer = params.get("offer")
|
|
594
599
|
if not isinstance(offer, str) or offer == "":
|
|
595
600
|
raise _reject("invalid-message", "offer must be a non-empty SDP string")
|
|
596
|
-
tools =
|
|
601
|
+
tools = _context_tools(ctx.caller.context)
|
|
597
602
|
if self._starting:
|
|
598
603
|
raise _reject("voice-active", "a voice session is already live")
|
|
599
604
|
session = self.live()
|
|
@@ -162,6 +162,7 @@ class RunDriver:
|
|
|
162
162
|
self.client_id = client_id
|
|
163
163
|
self._side_seqs: dict[str, int] = {}
|
|
164
164
|
self._seq = 0
|
|
165
|
+
self._ctx_seq = 0
|
|
165
166
|
self._res: dict[int, list[dict[str, Any]]] = {}
|
|
166
167
|
self._posts: list["asyncio.Task[None]"] = []
|
|
167
168
|
self.replica: dict[str, Any] = {}
|
|
@@ -180,6 +181,20 @@ class RunDriver:
|
|
|
180
181
|
def _spawn(self, frame: dict[str, Any]) -> None:
|
|
181
182
|
self._posts.append(asyncio.create_task(self._submit(frame)))
|
|
182
183
|
|
|
184
|
+
async def set_context(self, value: dict[str, Any]) -> None:
|
|
185
|
+
"""Sync this client's stored context (one root replace), as contextSync does."""
|
|
186
|
+
self._ctx_seq += 1
|
|
187
|
+
await self._submit(
|
|
188
|
+
{
|
|
189
|
+
"ctx": [
|
|
190
|
+
{
|
|
191
|
+
"ops": [{"op": "replace", "path": [], "value": value}],
|
|
192
|
+
"seq": self._ctx_seq,
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
183
198
|
def post(self, method: str, params: Any = None) -> int:
|
|
184
199
|
"""Background frame POST; settle via ``res(seq)``."""
|
|
185
200
|
self._seq += 1
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"""Frontend function tools contract: ``
|
|
2
|
-
provider
|
|
1
|
+
"""Frontend function tools contract: the caller's synced context ``tools``
|
|
2
|
+
map reaches the provider at ``voice/start``, matching calls project into ``state["voice"]["toolCalls"]``,
|
|
3
3
|
``voice/temp-frontend-tool-result`` answers them, and the delegated-task
|
|
4
4
|
handler's ``call_frontend_tool`` rides the same projection."""
|
|
5
5
|
|
|
@@ -23,11 +23,13 @@ async def until(predicate, timeout: float = 5) -> None:
|
|
|
23
23
|
assert asyncio.get_running_loop().time() < deadline, "condition never held"
|
|
24
24
|
await asyncio.sleep(0.01)
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
TOOLS = {
|
|
27
|
+
"get_local_time": {
|
|
28
|
+
"description": "Read the user's local date and time.",
|
|
29
|
+
"parameters": {"type": "object", "properties": {}},
|
|
30
|
+
},
|
|
30
31
|
}
|
|
32
|
+
TOOL = {"name": "get_local_time", **TOOLS["get_local_time"]}
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def call_of(events, call_id: str = "call-1") -> None:
|
|
@@ -38,25 +40,34 @@ def call_of(events, call_id: str = "call-1") -> None:
|
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
async def
|
|
43
|
+
async def test_start_reads_tools_from_the_caller_context():
|
|
42
44
|
bench = Bench()
|
|
43
45
|
async with voice_host(bench) as (drv, _app):
|
|
44
|
-
|
|
46
|
+
await drv.set_context(
|
|
47
|
+
{"tools": {**TOOLS, "no_desc": {"parameters": {"type": "object"}}}}
|
|
48
|
+
)
|
|
49
|
+
rsp = await drv.command("voice/start", {"offer": OFFER})
|
|
45
50
|
assert rsp["type"] == "accepted", rsp
|
|
46
|
-
assert bench.provider.calls[0] == (
|
|
51
|
+
assert bench.provider.calls[0] == (
|
|
52
|
+
"start",
|
|
53
|
+
OFFER,
|
|
54
|
+
[],
|
|
55
|
+
[TOOL, {"name": "no_desc", "description": "", "parameters": {"type": "object"}}],
|
|
56
|
+
)
|
|
47
57
|
|
|
48
58
|
|
|
49
|
-
async def
|
|
59
|
+
async def test_malformed_context_tools_reject_invalid_message():
|
|
50
60
|
bench = Bench()
|
|
51
61
|
async with voice_host(bench) as (drv, _app):
|
|
52
62
|
for tools in (
|
|
53
|
-
"not-a-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
"not-a-map",
|
|
64
|
+
{"": {"description": "", "parameters": {}}},
|
|
65
|
+
{"a": {"description": "d"}},
|
|
66
|
+
{"a": "not-a-spec"},
|
|
57
67
|
):
|
|
68
|
+
await drv.set_context({"tools": tools})
|
|
58
69
|
assert_rejected(
|
|
59
|
-
await drv.command("voice/start", {"offer": OFFER
|
|
70
|
+
await drv.command("voice/start", {"offer": OFFER}),
|
|
60
71
|
"invalid-message",
|
|
61
72
|
)
|
|
62
73
|
assert bench.provider.calls == []
|
|
@@ -65,7 +76,8 @@ async def test_malformed_tools_reject_invalid_message():
|
|
|
65
76
|
async def test_client_tool_call_projects_into_state():
|
|
66
77
|
bench = Bench()
|
|
67
78
|
async with voice_host(bench) as (drv, _app):
|
|
68
|
-
await drv.
|
|
79
|
+
await drv.set_context({"tools": TOOLS})
|
|
80
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
69
81
|
call_of(bench.events())
|
|
70
82
|
await drv.wait(
|
|
71
83
|
lambda s: (voice_of(s) or {}).get("toolCalls")
|
|
@@ -78,7 +90,8 @@ async def test_client_tool_call_projects_into_state():
|
|
|
78
90
|
async def test_tool_result_forwards_and_clears_the_pending_call():
|
|
79
91
|
bench = Bench()
|
|
80
92
|
async with voice_host(bench) as (drv, _app):
|
|
81
|
-
await drv.
|
|
93
|
+
await drv.set_context({"tools": TOOLS})
|
|
94
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
82
95
|
call_of(bench.events())
|
|
83
96
|
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
84
97
|
rsp = await drv.command(
|
|
@@ -93,7 +106,8 @@ async def test_tool_result_forwards_and_clears_the_pending_call():
|
|
|
93
106
|
async def test_unknown_call_id_rejects_unknown_tool_call():
|
|
94
107
|
bench = Bench()
|
|
95
108
|
async with voice_host(bench) as (drv, _app):
|
|
96
|
-
await drv.
|
|
109
|
+
await drv.set_context({"tools": TOOLS})
|
|
110
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
97
111
|
assert_rejected(
|
|
98
112
|
await drv.command(
|
|
99
113
|
"voice/temp-frontend-tool-result",
|
|
@@ -113,7 +127,8 @@ async def test_executor_call_projects_and_the_result_resolves_the_await():
|
|
|
113
127
|
|
|
114
128
|
bench.task_handler = handler
|
|
115
129
|
async with voice_host(bench) as (drv, _app):
|
|
116
|
-
await drv.
|
|
130
|
+
await drv.set_context({"tools": TOOLS})
|
|
131
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
117
132
|
bench.events().task_requested("d1", "what time is it locally?")
|
|
118
133
|
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
119
134
|
[call] = drv.replica["voice"]["toolCalls"]
|
|
@@ -142,7 +157,8 @@ async def test_executor_call_with_undeclared_tool_raises_in_the_handler():
|
|
|
142
157
|
|
|
143
158
|
bench.task_handler = handler
|
|
144
159
|
async with voice_host(bench) as (drv, _app):
|
|
145
|
-
await drv.
|
|
160
|
+
await drv.set_context({"tools": TOOLS})
|
|
161
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
146
162
|
bench.events().task_requested("d1", "open up")
|
|
147
163
|
await until(
|
|
148
164
|
lambda: any(
|
|
@@ -170,7 +186,8 @@ async def test_session_end_fails_a_pending_executor_call():
|
|
|
170
186
|
|
|
171
187
|
bench.task_handler = handler
|
|
172
188
|
async with voice_host(bench) as (drv, _app):
|
|
173
|
-
await drv.
|
|
189
|
+
await drv.set_context({"tools": TOOLS})
|
|
190
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
174
191
|
bench.events().task_requested("d1", "what time is it?")
|
|
175
192
|
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
176
193
|
rsp = await drv.command("voice/end")
|
|
@@ -183,7 +200,8 @@ async def test_session_end_fails_a_pending_executor_call():
|
|
|
183
200
|
async def test_tool_result_from_other_client_rejects_not_voice_owner():
|
|
184
201
|
bench = Bench()
|
|
185
202
|
async with voice_host(bench) as (drv, app):
|
|
186
|
-
await drv.
|
|
203
|
+
await drv.set_context({"tools": TOOLS})
|
|
204
|
+
await drv.command("voice/start", {"offer": OFFER})
|
|
187
205
|
call_of(bench.events())
|
|
188
206
|
await drv.wait(lambda s: (voice_of(s) or {}).get("toolCalls") != [])
|
|
189
207
|
rsp = await side_command(
|
|
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
|