harness-sdk-python 0.31.1__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.1 → harness_sdk_python-0.32.0}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/voice.py +29 -23
  4. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/run_helpers.py +15 -0
  5. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_voice.py +13 -1
  6. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_voice_frontend_tools.py +40 -22
  7. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/.gitignore +0 -0
  8. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/README.md +0 -0
  9. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/examples/__init__.py +0 -0
  10. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/examples/runs_app.py +0 -0
  11. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/__init__.py +0 -0
  12. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/checkpoints.py +0 -0
  13. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/deepagents.py +0 -0
  14. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/src/harness_sdk/run_manager.py +0 -0
  17. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_admit_steer.py +0 -0
  18. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_applied_visibility.py +0 -0
  19. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_batches.py +0 -0
  20. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_branch_anchor.py +0 -0
  21. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_checkpoints.py +0 -0
  22. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_deepagents_messages.py +0 -0
  23. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_dispatching.py +0 -0
  24. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_durability.py +0 -0
  25. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_edit_dispatched.py +0 -0
  26. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_edit_reload.py +0 -0
  27. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_enqueue.py +0 -0
  28. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_input_required.py +0 -0
  30. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_settle.py +0 -0
  40. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_steer.py +0 -0
  41. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_stop_continue.py +0 -0
  42. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_stopping_window.py +0 -0
  43. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/test_voice_delegation.py +0 -0
  44. {harness_sdk_python-0.31.1 → harness_sdk_python-0.32.0}/tests/voice_helpers.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.31.1
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
7
7
  Requires-Python: <4.0,>=3.12
8
- Requires-Dist: pinned<0.15,>=0.14.0
8
+ Requires-Dist: pinned<0.15,>=0.14.6
9
9
  Requires-Dist: statewire<0.13,>=0.12.3
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.31.1"
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
7
  requires-python = ">=3.12,<4.0"
8
- dependencies = ["statewire>=0.12.3,<0.13", "pinned>=0.14.0,<0.15"]
8
+ dependencies = ["statewire>=0.12.3,<0.13", "pinned>=0.14.6,<0.15"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.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, 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()
@@ -672,11 +677,11 @@ def voice_manager() -> Callable[[Callable[..., Any]], VoiceManagerDescriptor]:
672
677
  return decorate
673
678
 
674
679
 
675
- def _voice_of(host: Any) -> VoiceManagerNamespace | None:
680
+ def _voice_of(host: Any, *, create: bool = True) -> VoiceManagerNamespace | None:
676
681
  for cls in type(host).__mro__:
677
682
  for name, member in vars(cls).items():
678
683
  if isinstance(member, VoiceManagerDescriptor):
679
- return getattr(host, name)
684
+ return getattr(host, name) if create else member.peek(host)
680
685
  return None
681
686
 
682
687
 
@@ -698,7 +703,8 @@ async def end_voice(host: Any, *, ctx: Any) -> None:
698
703
 
699
704
  def voice_client_departed(host: Any, client_id: str) -> None:
700
705
  """The Statewire ``client_departed`` hook body: routes to the host's declared voice manager."""
701
- namespace = _voice_of(host)
706
+ # peek: instantiating here would stamp voice state after the attach snapshot
707
+ namespace = _voice_of(host, create=False)
702
708
  if namespace is not None:
703
709
  namespace.client_departed(client_id)
704
710
 
@@ -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
@@ -4,7 +4,7 @@
4
4
  import asyncio
5
5
 
6
6
  from run_helpers import _statement, assert_rejected
7
- from statewire_helpers import post_frame, stream_of
7
+ from statewire_helpers import apply_ops, post_frame, stream_of
8
8
  from voice_helpers import (
9
9
  ANSWER,
10
10
  OFFER,
@@ -316,6 +316,18 @@ async def test_reattach_within_grace_keeps_the_session(monkeypatch):
316
316
  assert not bench.provider.closed
317
317
 
318
318
 
319
+ async def test_departure_before_any_voice_command_leaves_state_untouched():
320
+ bench = Bench()
321
+ async with voice_host(bench) as (drv, app):
322
+ async with stream_of(app, client_id="c2") as stream:
323
+ await stream.next_event()
324
+ await asyncio.sleep(0.05)
325
+ assert "voice" not in drv.replica
326
+ async with stream_of(app, client_id="c3") as stream:
327
+ snapshot = apply_ops({}, await stream.next_event())
328
+ assert "voice" not in snapshot
329
+
330
+
319
331
  async def test_start_over_a_detached_owner_replaces_the_session():
320
332
  bench = Bench()
321
333
  async with voice_host(bench) as (drv, app):
@@ -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(