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.
Files changed (44) hide show
  1. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/voice.py +25 -20
  4. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/run_helpers.py +15 -0
  5. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_voice_frontend_tools.py +40 -22
  6. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/.gitignore +0 -0
  7. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/README.md +0 -0
  8. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/examples/__init__.py +0 -0
  9. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/examples/runs_app.py +0 -0
  10. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/__init__.py +0 -0
  11. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/checkpoints.py +0 -0
  12. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/deepagents.py +0 -0
  13. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/src/harness_sdk/run_manager.py +0 -0
  16. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_admit_steer.py +0 -0
  17. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_applied_visibility.py +0 -0
  18. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_batches.py +0 -0
  19. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_branch_anchor.py +0 -0
  20. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_checkpoints.py +0 -0
  21. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_deepagents_messages.py +0 -0
  22. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_dispatching.py +0 -0
  23. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_durability.py +0 -0
  24. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_edit_dispatched.py +0 -0
  25. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_edit_reload.py +0 -0
  26. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_enqueue.py +0 -0
  27. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_fenced_postgres.py +0 -0
  28. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_input_required.py +0 -0
  29. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_linear_thread.py +0 -0
  30. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_meta.py +0 -0
  31. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_mint_concurrency.py +0 -0
  32. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_outcomes.py +0 -0
  33. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_placement.py +0 -0
  34. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_prepare_hooks.py +0 -0
  35. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_restore.py +0 -0
  36. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_rewind_during_run.py +0 -0
  37. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_run_crash_sweep.py +0 -0
  38. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_settle.py +0 -0
  39. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_steer.py +0 -0
  40. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_stop_continue.py +0 -0
  41. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_stopping_window.py +0 -0
  42. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_voice.py +0 -0
  43. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/test_voice_delegation.py +0 -0
  44. {harness_sdk_python-0.31.2 → harness_sdk_python-0.32.0}/tests/voice_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.31.2
3
+ Version: 0.32.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.31.2"
3
+ version = "0.32.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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, 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"] =
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 _validated_tools(tools: Any) -> "list[VoiceManager.ToolDef]":
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, list):
81
- raise _reject("invalid-message", "tools must be a list")
82
- names: set[str] = set()
83
- for tool in tools:
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
- not isinstance(tool, dict)
86
- or not isinstance(tool.get("name"), str)
87
- or tool["name"] == ""
88
- or not isinstance(tool.get("description"), str)
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 {{name, description, parameters}}, got {tool!r}",
94
+ f"context tools entries must be name -> {{description?, parameters}}, got {name!r}: {spec!r}",
94
95
  )
95
- if tool["name"] in names:
96
- raise _reject("invalid-message", f"duplicate tool name {tool['name']!r}")
97
- names.add(tool["name"])
98
- return tools
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 client declares at ``voice/start``."""
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 = _validated_tools(params.get("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: ``voice/start {tools}`` reaches the
2
- provider, matching calls project into ``state["voice"]["toolCalls"]``,
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
- TOOL = {
27
- "name": "get_local_time",
28
- "description": "Read the user's local date and time.",
29
- "parameters": {"type": "object", "properties": {}},
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 test_start_passes_tools_to_the_provider():
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
- rsp = await drv.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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] == ("start", OFFER, [], [TOOL])
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 test_malformed_tools_reject_invalid_message():
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-list",
54
- [{"name": "", "description": "", "parameters": {}}],
55
- [{"name": "a", "description": "d"}],
56
- [TOOL, TOOL],
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, "tools": tools}),
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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.command("voice/start", {"offer": OFFER, "tools": [TOOL]})
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(