harness-sdk-python 0.37.0__tar.gz → 0.38.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 (73) hide show
  1. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/.gitignore +7 -0
  2. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/PKG-INFO +5 -2
  3. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/pyproject.toml +3 -2
  4. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/__init__.py +15 -5
  5. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/deepagents.py +78 -23
  6. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/deepagents_host.py +80 -3
  7. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/interest.py +6 -0
  8. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/messages.py +29 -0
  9. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/protocol.py +4 -3
  10. harness_sdk_python-0.38.0/src/harness_sdk/providers/__init__.py +1 -0
  11. harness_sdk_python-0.38.0/src/harness_sdk/providers/gpt_live.py +201 -0
  12. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/run_manager.py +7 -1
  13. harness_sdk_python-0.38.0/src/harness_sdk/threads.py +189 -0
  14. harness_sdk_python-0.38.0/src/harness_sdk/voice.py +562 -0
  15. harness_sdk_python-0.38.0/src/harness_sdk/voice_thread.py +154 -0
  16. harness_sdk_python-0.38.0/src/harness_sdk/wire.py +209 -0
  17. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_deepagents_thread.py +96 -0
  18. harness_sdk_python-0.38.0/tests/test_deepagents_voice.py +129 -0
  19. harness_sdk_python-0.38.0/tests/test_gpt_live.py +219 -0
  20. harness_sdk_python-0.38.0/tests/test_imports.py +24 -0
  21. harness_sdk_python-0.38.0/tests/test_threads.py +258 -0
  22. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_voice.py +195 -193
  23. harness_sdk_python-0.38.0/tests/test_voice_delegation.py +160 -0
  24. harness_sdk_python-0.38.0/tests/test_wire.py +269 -0
  25. harness_sdk_python-0.38.0/tests/voice_helpers.py +279 -0
  26. harness_sdk_python-0.37.0/src/harness_sdk/voice.py +0 -805
  27. harness_sdk_python-0.37.0/src/harness_sdk/wire.py +0 -114
  28. harness_sdk_python-0.37.0/tests/test_voice_delegation.py +0 -327
  29. harness_sdk_python-0.37.0/tests/test_voice_frontend_tools.py +0 -214
  30. harness_sdk_python-0.37.0/tests/test_wire.py +0 -108
  31. harness_sdk_python-0.37.0/tests/voice_helpers.py +0 -234
  32. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/README.md +0 -0
  33. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/examples/__init__.py +0 -0
  34. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/examples/runs_app.py +0 -0
  35. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/checkpoints.py +0 -0
  36. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/fenced_postgres.py +0 -0
  37. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/langgraph.py +0 -0
  38. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/linear_thread.py +0 -0
  39. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/src/harness_sdk/parts.py +0 -0
  40. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/run_helpers.py +0 -0
  41. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_admit_steer.py +0 -0
  42. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_applied_visibility.py +0 -0
  43. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_batches.py +0 -0
  44. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_branch_anchor.py +0 -0
  45. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_checkpoints.py +0 -0
  46. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_dispatching.py +0 -0
  47. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_durability.py +0 -0
  48. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_edit_dispatched.py +0 -0
  49. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_edit_reload.py +0 -0
  50. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_enqueue.py +0 -0
  51. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_fenced_postgres.py +0 -0
  52. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_input_required.py +0 -0
  53. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_interest.py +0 -0
  54. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_langgraph.py +0 -0
  55. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_linear_thread.py +0 -0
  56. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_meta.py +0 -0
  57. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_mint_concurrency.py +0 -0
  58. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_outcomes.py +0 -0
  59. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_parts.py +0 -0
  60. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_placement.py +0 -0
  61. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_prepare_hooks.py +0 -0
  62. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_restore.py +0 -0
  63. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_rewind_during_run.py +0 -0
  64. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_run_admission.py +0 -0
  65. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_run_crash_sweep.py +0 -0
  66. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_run_eviction.py +0 -0
  67. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_run_ownership.py +0 -0
  68. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_run_sync.py +0 -0
  69. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_settle.py +0 -0
  70. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_steer.py +0 -0
  71. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_stop_continue.py +0 -0
  72. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_stopping_window.py +0 -0
  73. {harness_sdk_python-0.37.0 → harness_sdk_python-0.38.0}/tests/test_undo.py +0 -0
@@ -28,3 +28,10 @@ apps/docs/.docs
28
28
 
29
29
  MAINTAIN_EPOCH.md
30
30
  DOCS_EPOCH.md
31
+
32
+ # terraform
33
+ .terraform/
34
+ *.tfstate
35
+ *.tfstate.*
36
+ *.tfvars
37
+ !*.tfvars.example
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.37.0
3
+ Version: 0.38.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
8
  Requires-Dist: pinned<0.16,>=0.15.2
9
- Requires-Dist: statewire<0.19,>=0.18.0
9
+ Requires-Dist: statewire<0.20,>=0.19.0
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
@@ -15,6 +15,9 @@ Requires-Dist: langgraph>=1.0.0; extra == 'deepagents'
15
15
  Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'deepagents'
16
16
  Provides-Extra: postgres
17
17
  Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
18
+ Provides-Extra: voice
19
+ Requires-Dist: httpx>=0.27; extra == 'voice'
20
+ Requires-Dist: websockets>=13; extra == 'voice'
18
21
  Description-Content-Type: text/markdown
19
22
 
20
23
  # harness-sdk-python
@@ -1,14 +1,15 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.37.0"
3
+ version = "0.38.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.18.0,<0.19", "pinned>=0.15.2,<0.16"]
8
+ dependencies = ["statewire>=0.19.0,<0.20", "pinned>=0.15.2,<0.16"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
12
+ voice = ["httpx>=0.27", "websockets>=13"]
12
13
  deepagents = [
13
14
  "deepagents>=0.6.12",
14
15
  "langgraph>=1.0.0",
@@ -1,4 +1,4 @@
1
- from .protocol import HARNESS_PROTOCOL
1
+ from .protocol import HARNESS_PROTOCOL, HARNESS_THREADS_PROTOCOL
2
2
  from .checkpoints import (
3
3
  CheckpointIndex,
4
4
  CheckpointStore,
@@ -21,22 +21,31 @@ from .messages import MAIN_NS, MessageTree
21
21
  from .parts import PartsAssembler
22
22
  from .linear_thread import linear_thread
23
23
  from .run_manager import RunManager, RunManagerDescriptor, run_manager
24
- from .wire import CONTEXT, INTEREST, HarnessMessages
24
+ from .threads import DuplicateThread, HarnessThreads, MemoryThreadStore, ThreadRecord, ThreadStatus, ThreadStore, UnknownThread
25
+ from .wire import INTEREST, HarnessMessages
25
26
  from .voice import (
26
27
  VoiceManager,
27
28
  VoiceManagerDescriptor,
28
29
  VoiceProvider,
29
30
  VoiceTurnCommitDeferred,
30
31
  end_voice,
32
+ set_voice_mic,
31
33
  start_voice,
32
- temp_frontend_tool_result,
33
34
  voice_client_departed,
34
35
  voice_manager,
35
36
  )
37
+ from .voice_thread import VoiceThread
36
38
 
37
39
  __all__ = [
38
- "CONTEXT",
39
40
  "HARNESS_PROTOCOL",
41
+ "HARNESS_THREADS_PROTOCOL",
42
+ "HarnessThreads",
43
+ "MemoryThreadStore",
44
+ "ThreadRecord",
45
+ "ThreadStatus",
46
+ "ThreadStore",
47
+ "DuplicateThread",
48
+ "UnknownThread",
40
49
  "HarnessMessages",
41
50
  "INTEREST",
42
51
  "CheckpointIndex",
@@ -57,6 +66,7 @@ __all__ = [
57
66
  "VoiceManager",
58
67
  "VoiceManagerDescriptor",
59
68
  "VoiceProvider",
69
+ "VoiceThread",
60
70
  "VoiceTurnCommitDeferred",
61
71
  "checkpoint_thread",
62
72
  "check_request",
@@ -71,8 +81,8 @@ __all__ = [
71
81
  "linear_thread",
72
82
  "resolve",
73
83
  "run_manager",
84
+ "set_voice_mic",
74
85
  "start_voice",
75
- "temp_frontend_tool_result",
76
86
  "voice_client_departed",
77
87
  "voice_manager",
78
88
  ]
@@ -2,7 +2,9 @@
2
2
  langgraph checkpointer is the agent's resumption source; the checkpoint
3
3
  index rows carry the harness messages (``value``) the wire projects. A
4
4
  rewind forks the graph at the anchor message's checkpoint; a send off the
5
- head chain forks the same way.
5
+ head chain forks the same way. ``run_subagent`` runs a turn in a subagent
6
+ namespace on the langgraph thread ``<thread_id>/<ns>``, concurrent with the
7
+ main run; its rows persist under ``checkpoint_ns == ns``.
6
8
  """
7
9
 
8
10
  from typing import Any, Callable
@@ -17,6 +19,7 @@ from .checkpoints import (
17
19
  )
18
20
  from .messages import MAIN_NS, MessageTree
19
21
  from .parts import PartsAssembler, text_of
22
+ from .voice_thread import upsert_voice_turn
20
23
 
21
24
  _TYPE_TO_ROLE = {"human": "user", "ai": "assistant", "tool": "assistant", "system": "assistant"}
22
25
 
@@ -41,6 +44,10 @@ def _checkpoint_ns(ns: str) -> str:
41
44
  return "" if ns == MAIN_NS else ns
42
45
 
43
46
 
47
+ def _root_of(ns: str) -> str:
48
+ return ns.split("|", 1)[0]
49
+
50
+
44
51
  class DeepagentsThread:
45
52
  """One thread's harness messages over a compiled deepagents graph: boot, turn planning, stream folding, ingest, subagents."""
46
53
 
@@ -67,7 +74,7 @@ class DeepagentsThread:
67
74
  self._input_checkpoint_seen = False
68
75
  self._assemblers: dict[str, PartsAssembler] = {}
69
76
  self._touched: set[tuple[str, str]] = set()
70
- self._pending_subagents: dict[str, str] = {}
77
+ self._subagents: set[str] = set()
71
78
  self.interrupt: dict[str, Any] | None = None
72
79
 
73
80
  async def boot(self) -> None:
@@ -84,7 +91,9 @@ class DeepagentsThread:
84
91
  thread.head = max(thread.messages.values(), key=lambda m: m["seq"])["id"]
85
92
  self._thread_state(ns)
86
93
  if self._thread.transcript:
87
- self.tree.set_head(MAIN_NS, self._harness_id(self._thread.transcript[-1]["id"]))
94
+ # voice turns commit after the run they preceded: the checkpoint tail may sit above the tree leaf
95
+ last = self._harness_id(self._thread.transcript[-1]["id"])
96
+ self.tree.set_head(MAIN_NS, self.tree.thread(MAIN_NS).leaf_of(last))
88
97
  else:
89
98
  self.tree.thread(MAIN_NS)
90
99
  self._thread_state(MAIN_NS)
@@ -105,18 +114,7 @@ class DeepagentsThread:
105
114
  self._threads[ns] = {**current, **fields, "headId": thread.head}
106
115
 
107
116
  async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
108
- thread = self.tree.thread(MAIN_NS)
109
- if message_id is None:
110
- return {"isLeaf": thread.head is None}
111
- message = thread.messages.get(message_id)
112
- if message is None:
113
- return None
114
- return {
115
- "parentId": message["parentId"],
116
- "role": message["role"],
117
- "isLeaf": not thread.children.get(message_id),
118
- "onActiveBranch": True,
119
- }
117
+ return self.tree.meta(MAIN_NS, message_id)
120
118
 
121
119
  def config(self) -> dict[str, Any]:
122
120
  return {"configurable": {"thread_id": self._thread_id}}
@@ -248,8 +246,8 @@ class DeepagentsThread:
248
246
  if langchain_id not in plain(ids):
249
247
  ids.append(langchain_id)
250
248
 
251
- def _spawn(self, ns: str, description: str) -> None:
252
- main = self._assemblers.get(MAIN_NS)
249
+ def _spawn(self, parent: str, ns: str, description: str) -> None:
250
+ main = self._assemblers.get(parent)
253
251
  if main is None:
254
252
  return
255
253
  for part in plain(main.parts):
@@ -289,7 +287,7 @@ class DeepagentsThread:
289
287
  )
290
288
  self._touched.add((ns, first.id))
291
289
  self._thread_state(ns, status="streaming")
292
- self._spawn(ns, text_of(first.content))
290
+ self._spawn(_ns_of(namespace[:-1]), ns, text_of(first.content))
293
291
  self._changed()
294
292
  return
295
293
  if mode == "messages":
@@ -303,8 +301,8 @@ class DeepagentsThread:
303
301
  assembler = self._assemblers.get(ns)
304
302
  if assembler is not None:
305
303
  self._fold_id(assembler, message.id)
306
- sub_ns = meta.get("langgraph_checkpoint_ns")
307
- if ns == MAIN_NS and sub_ns in self.tree.threads:
304
+ sub_ns = _ns_of((*namespace, *meta.get("langgraph_checkpoint_ns", "").split("|")))
305
+ if sub_ns in self.tree.threads:
308
306
  assembler.amend(message.tool_call_id, metadata={"ns": sub_ns})
309
307
  self._finish(sub_ns)
310
308
  assembler.tool_result(message)
@@ -336,15 +334,53 @@ class DeepagentsThread:
336
334
  await self._thread.ingest(langgraph_snapshot(captured))
337
335
  if self._input_checkpoint_seen:
338
336
  self._confirm_admission()
339
- for ns in list(self._assemblers):
337
+ for ns in self._turn_namespaces():
340
338
  if self.interrupt is None or ns != MAIN_NS:
341
339
  self._finish(ns)
342
340
  for ns in self.tree.threads:
343
- if ns != MAIN_NS:
341
+ if ns != MAIN_NS and _root_of(ns) not in self._subagents:
344
342
  self._thread_state(ns, status="idle")
345
343
  await self._persist()
346
344
  self._changed()
347
345
 
346
+ def _turn_namespaces(self) -> list[str]:
347
+ return [ns for ns in self._assemblers if _root_of(ns) not in self._subagents]
348
+
349
+ async def run_subagent(self, ns: str, message: dict[str, Any]) -> None:
350
+ """Run a harness user ``message`` as a turn in subagent namespace ``ns``: its reply streams into the tree and persists under ``checkpoint_ns == ns``."""
351
+ from langchain_core.messages import HumanMessage
352
+
353
+ if ns == MAIN_NS or "|" in ns:
354
+ raise ValueError(f"subagent namespace must be a root namespace other than {MAIN_NS!r}, got {ns!r}")
355
+ if ns in self._subagents:
356
+ raise RuntimeError(f"subagent {ns!r} is already running")
357
+ thread = self.tree.thread(ns)
358
+ self.tree.add(ns, {**message, "parentId": thread.head})
359
+ self._touched.add((ns, message["id"]))
360
+ self._thread_state(ns, status="streaming")
361
+ self._subagents.add(ns)
362
+ self._changed()
363
+ config = {"configurable": {"thread_id": f"{self._thread_id}/{ns}"}}
364
+ payload = {"messages": [HumanMessage(id=message["id"], content=_user_text(message))]}
365
+ try:
366
+ async for namespace, mode, event in self._agent.astream(
367
+ payload, config=config, stream_mode=["messages", "updates", "checkpoints"], subgraphs=True
368
+ ):
369
+ self.append_event((ns, *namespace), mode, event)
370
+ finally:
371
+ self._subagents.discard(ns)
372
+ for sub in [sub for sub in self._assemblers if _root_of(sub) == ns]:
373
+ self._finish(sub)
374
+ for sub in [sub for sub in self.tree.threads if _root_of(sub) == ns]:
375
+ self._thread_state(sub, status="idle")
376
+ await self._persist()
377
+ self._changed()
378
+
379
+ async def persist_message(self, ns: str, message_id: str) -> None:
380
+ """Write one tree message's index row."""
381
+ self._touched.add((ns, message_id))
382
+ await self._persist()
383
+
348
384
  async def _persist(self) -> None:
349
385
  touched, self._touched = self._touched, set()
350
386
  rows = [
@@ -361,13 +397,32 @@ class DeepagentsThread:
361
397
  if rows:
362
398
  await self._index.upsert_messages(rows)
363
399
 
400
+ async def append_turns(self, turns: list[dict[str, Any]]) -> None:
401
+ """Commit ``{id, role, text}`` voice turns: one state update on the main tip carries them as messages and the tree entries turn durable."""
402
+ from langchain_core.messages import AIMessage, HumanMessage
403
+
404
+ if not turns:
405
+ raise ValueError("turns must be non-empty")
406
+ entries = [
407
+ (HumanMessage if turn["role"] == "user" else AIMessage)(id=turn["id"], content=turn["text"])
408
+ for turn in turns
409
+ ]
410
+ await self._agent.aupdate_state(self.config(), {"messages": entries}, as_node="__start__")
411
+ for turn in turns:
412
+ upsert_voice_turn(self.tree, turn, ephemeral=False)
413
+ self._touched.add((MAIN_NS, turn["id"]))
414
+ await self._thread.ingest_latest()
415
+ self._thread_state(MAIN_NS)
416
+ await self._persist()
417
+ self._changed()
418
+
364
419
  def abandon_turn(self) -> None:
365
420
  """Discard a stream capture and any fork planned for the abandoned turn."""
366
421
  self._captured = None
367
422
  self._admission = None
368
423
  self._input_checkpoint_seen = False
369
424
  self.interrupt = None
370
- for ns in list(self._assemblers):
425
+ for ns in self._turn_namespaces():
371
426
  self._finish(ns)
372
427
  self._thread.abandon_plan()
373
428
 
@@ -1,23 +1,32 @@
1
1
  """DeepagentsHost: a Statewire host serving one deepagents thread over the
2
2
  harness protocol. Subclasses implement ``build_agent`` and the app assigns
3
- ``checkpointer`` and ``index`` before serving.
3
+ ``checkpointer`` and ``index`` before serving. A ``@voice_manager()`` on the
4
+ subclass enables the ``voice/*`` commands: voice turns commit as one
5
+ checkpoint each flush once no run is in flight, delegated tasks run as
6
+ subagent turns through ``run_subagent``.
4
7
  """
5
8
 
6
9
  import asyncio
7
10
  import contextlib
11
+ import logging
8
12
  from typing import Any
9
13
 
10
14
  from statewire import Statewire, command
11
15
 
12
16
  from .checkpoints import CheckpointIndex
13
17
  from .deepagents import DeepagentsThread
14
- from .langgraph import FrontierCheckpointer
18
+ from .messages import MAIN_NS
15
19
  from .protocol import HARNESS_PROTOCOL
16
20
  from .run_manager import RunManager, run_manager
21
+ from .voice import VoiceTurnCommitDeferred, end_voice, set_voice_mic, start_voice, voice_client_departed
22
+ from .voice_thread import VoiceThread, is_ephemeral
17
23
  from .wire import HarnessMessages
18
24
 
25
+ logger = logging.getLogger(__name__)
26
+ _HALTED = ("stopped", "error")
19
27
 
20
- class DeepagentsHost(HarnessMessages, Statewire):
28
+
29
+ class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
21
30
  """One thread: ``build_agent(checkpointer)`` returns the compiled deepagents graph."""
22
31
 
23
32
  protocols = (HARNESS_PROTOCOL,)
@@ -28,6 +37,8 @@ class DeepagentsHost(HarnessMessages, Statewire):
28
37
  raise NotImplementedError(f"{type(self).__name__}.build_agent")
29
38
 
30
39
  async def lifespan(self):
40
+ from .langgraph import FrontierCheckpointer
41
+
31
42
  if type(self).checkpointer is None or type(self).index is None:
32
43
  raise RuntimeError(f"{type(self).__name__}.checkpointer and .index must be assigned before serving")
33
44
  checkpointer = FrontierCheckpointer(
@@ -46,11 +57,77 @@ class DeepagentsHost(HarnessMessages, Statewire):
46
57
  changed=self.refresh_messages,
47
58
  )
48
59
  await self.messages.boot()
60
+ self._voice_journal: dict[str, dict[str, Any]] = {}
49
61
  yield
50
62
 
51
63
  async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
52
64
  return await self.messages.get_message_meta(message_id)
53
65
 
66
+ # ─── Voice ──────────────────────────────────────────────
67
+
68
+ def _runs_quiet(self) -> bool:
69
+ live = self.run.live()
70
+ return live is None or live.status.value in ("ready", *_HALTED)
71
+
72
+ async def commit_voice_turns(self, turns: list[dict[str, Any]]) -> None:
73
+ for turn in turns:
74
+ message = self.tree.get(MAIN_NS, turn["id"])
75
+ if message is None or is_ephemeral(message):
76
+ self._voice_journal[turn["id"]] = turn
77
+ if not self._runs_quiet():
78
+ raise VoiceTurnCommitDeferred(f"run {self.state['status']}")
79
+ await self._commit_voice_journal()
80
+
81
+ async def _commit_voice_journal(self) -> None:
82
+ turns, self._voice_journal = list(self._voice_journal.values()), {}
83
+ if turns:
84
+ await self.messages.append_turns(turns)
85
+
86
+ async def _deliver_voice_journal(self) -> None:
87
+ # a run dispatched between the settle and this write would race the checkpoint; the journal keeps the turns until the next settle.
88
+ # a journaled turn keeps its tree position; the graph state appends it after the run's messages
89
+ if not self._runs_quiet():
90
+ return
91
+ try:
92
+ await self._commit_voice_journal()
93
+ except Exception:
94
+ logger.exception("voice turns failed to commit after the run settled")
95
+
96
+ def drop_ephemeral_message(self, message_id: str) -> None:
97
+ if message_id not in self._voice_journal:
98
+ super().drop_ephemeral_message(message_id)
99
+
100
+ async def run_subagent(self, ns: str, message: dict[str, Any]) -> None:
101
+ await self.messages.run_subagent(ns, message)
102
+
103
+ async def delegate(self, task_id: str, text: str) -> str:
104
+ try:
105
+ return await super().delegate(task_id, text)
106
+ finally:
107
+ await self.messages.persist_message(MAIN_NS, f"voicetask_{task_id}")
108
+
109
+ def run_status_changed(self, status: str) -> None:
110
+ super().run_status_changed(status)
111
+ if status in ("ready", *_HALTED) and self._voice_journal:
112
+ self.create_task(self._deliver_voice_journal())
113
+
114
+ @command("voice/start")
115
+ async def voice_start(self, params, *, ctx):
116
+ return await start_voice(self, params, ctx=ctx)
117
+
118
+ @command("voice/end")
119
+ async def voice_end(self, params=None, *, ctx):
120
+ return await end_voice(self, ctx=ctx)
121
+
122
+ @command("voice/mic")
123
+ async def voice_mic(self, params, *, ctx):
124
+ return await set_voice_mic(self, params, ctx=ctx)
125
+
126
+ def client_departed(self, client_id: str) -> None:
127
+ voice_client_departed(self, client_id)
128
+
129
+ # ─── Runs ───────────────────────────────────────────────
130
+
54
131
  @run_manager(capabilities=("rewind",))
55
132
  async def run(self, ctx: RunManager.RunContext) -> Any:
56
133
  payload, config = await self.messages.plan_turn(ctx)
@@ -11,6 +11,12 @@ from .messages import MessageTree, Thread
11
11
 
12
12
  MAX_WINDOW = 200
13
13
  MAX_INTERESTS = 16
14
+ DEFAULT_WINDOW = 50
15
+
16
+
17
+ def head_request(window: int = DEFAULT_WINDOW) -> dict[str, Any]:
18
+ """The head window of the main thread: every attach starts with it."""
19
+ return {"ns": "main", "before": window, "follow": {"before": window}}
14
20
 
15
21
 
16
22
  class InterestError(ValueError):
@@ -73,6 +73,21 @@ class MessageTree:
73
73
  thread = self.threads.get(ns)
74
74
  return None if thread is None else thread.messages.get(message_id)
75
75
 
76
+ def meta(self, ns: str, message_id: str | None) -> dict[str, Any] | None:
77
+ """``get_message_meta`` over the tree: the root's leafness for None, else the message's parent, role, leafness."""
78
+ thread = self.thread(ns)
79
+ if message_id is None:
80
+ return {"isLeaf": thread.head is None}
81
+ message = thread.messages.get(message_id)
82
+ if message is None:
83
+ return None
84
+ return {
85
+ "parentId": message["parentId"],
86
+ "role": message["role"],
87
+ "isLeaf": not thread.children.get(message_id),
88
+ "onActiveBranch": True,
89
+ }
90
+
76
91
  def add(self, ns: str, message: dict[str, Any], *, head: bool = True) -> dict[str, Any]:
77
92
  """Add a message; ``seq`` is assigned unless present; the head moves to it by default."""
78
93
  _check_message(message)
@@ -103,6 +118,20 @@ class MessageTree:
103
118
  raise KeyError(f"message {message_id!r} is not in {ns!r}")
104
119
  thread.messages[message_id] = value
105
120
 
121
+ def remove(self, ns: str, message_id: str) -> None:
122
+ """Remove a message; its children re-parent to its parent, and a head on it moves to the parent."""
123
+ thread = self.thread(ns)
124
+ if message_id not in thread.messages:
125
+ raise KeyError(f"message {message_id!r} is not in {ns!r}")
126
+ parent = thread.messages.pop(message_id)["parentId"]
127
+ thread.children[parent].remove(message_id)
128
+ for child in thread.children.pop(message_id, []):
129
+ thread.messages[child]["parentId"] = parent
130
+ thread.children[parent].append(child)
131
+ thread.children[parent].sort(key=lambda i: thread.messages[i]["seq"])
132
+ if thread.head == message_id:
133
+ thread.head = parent
134
+
106
135
  def set_head(self, ns: str, message_id: str | None) -> None:
107
136
  thread = self.thread(ns)
108
137
  if message_id is not None and message_id not in thread.messages:
@@ -3,10 +3,11 @@ from statewire.protocol import Subprotocol
3
3
 
4
4
  HARNESS_PROTOCOL = Statewire.Protocol(
5
5
  "harness-sdk",
6
- version="2026-09-13",
7
- min_version="2026-09-13",
6
+ version="2026-09-30",
7
+ min_version="2026-09-30",
8
8
  subprotocols=(
9
9
  Subprotocol("interest", record="ephemeral"),
10
- Subprotocol("context", record="ephemeral"),
11
10
  ),
12
11
  )
12
+
13
+ HARNESS_THREADS_PROTOCOL = Statewire.Protocol("harness-sdk-threads", version="2026-09-13", min_version="2026-09-13")
@@ -0,0 +1 @@
1
+ """Voice providers: ``VoiceProvider`` implementations over vendor APIs."""