harness-sdk-python 0.37.1__tar.gz → 0.39.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 (80) hide show
  1. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/.gitignore +8 -0
  2. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/PKG-INFO +6 -3
  3. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/pyproject.toml +3 -2
  4. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/__init__.py +5 -4
  5. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents.py +151 -41
  6. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents_host.py +92 -36
  7. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/messages.py +29 -0
  8. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/parts.py +13 -0
  9. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/protocol.py +2 -3
  10. harness_sdk_python-0.39.0/src/harness_sdk/providers/__init__.py +1 -0
  11. harness_sdk_python-0.39.0/src/harness_sdk/providers/gpt_live.py +201 -0
  12. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/run_manager.py +101 -36
  13. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/threads.py +27 -8
  14. harness_sdk_python-0.39.0/src/harness_sdk/voice.py +629 -0
  15. harness_sdk_python-0.39.0/src/harness_sdk/voice_thread.py +163 -0
  16. harness_sdk_python-0.39.0/src/harness_sdk/wire.py +209 -0
  17. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_admit_steer.py +34 -3
  18. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_branch_anchor.py +46 -0
  19. harness_sdk_python-0.39.0/tests/test_deepagents_approval.py +99 -0
  20. harness_sdk_python-0.39.0/tests/test_deepagents_files.py +123 -0
  21. harness_sdk_python-0.39.0/tests/test_deepagents_outcome.py +133 -0
  22. harness_sdk_python-0.39.0/tests/test_deepagents_thread.py +336 -0
  23. harness_sdk_python-0.39.0/tests/test_deepagents_voice.py +130 -0
  24. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_edit_reload.py +9 -7
  25. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_enqueue.py +45 -0
  26. harness_sdk_python-0.39.0/tests/test_gpt_live.py +219 -0
  27. harness_sdk_python-0.39.0/tests/test_imports.py +24 -0
  28. harness_sdk_python-0.39.0/tests/test_parts.py +96 -0
  29. harness_sdk_python-0.39.0/tests/test_run_id_bounds.py +267 -0
  30. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_steer.py +44 -0
  31. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_threads.py +99 -10
  32. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_voice.py +251 -208
  33. harness_sdk_python-0.39.0/tests/test_voice_delegation.py +175 -0
  34. harness_sdk_python-0.39.0/tests/test_voice_events.py +175 -0
  35. harness_sdk_python-0.39.0/tests/test_wire.py +293 -0
  36. harness_sdk_python-0.39.0/tests/voice_helpers.py +283 -0
  37. harness_sdk_python-0.37.1/src/harness_sdk/voice.py +0 -805
  38. harness_sdk_python-0.37.1/src/harness_sdk/wire.py +0 -116
  39. harness_sdk_python-0.37.1/tests/test_deepagents_thread.py +0 -173
  40. harness_sdk_python-0.37.1/tests/test_parts.py +0 -49
  41. harness_sdk_python-0.37.1/tests/test_voice_delegation.py +0 -327
  42. harness_sdk_python-0.37.1/tests/test_voice_frontend_tools.py +0 -214
  43. harness_sdk_python-0.37.1/tests/test_wire.py +0 -111
  44. harness_sdk_python-0.37.1/tests/voice_helpers.py +0 -234
  45. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/README.md +0 -0
  46. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/examples/__init__.py +0 -0
  47. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/examples/runs_app.py +0 -0
  48. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/checkpoints.py +0 -0
  49. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/fenced_postgres.py +0 -0
  50. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/interest.py +0 -0
  51. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/langgraph.py +0 -0
  52. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/linear_thread.py +0 -0
  53. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/run_helpers.py +0 -0
  54. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_applied_visibility.py +0 -0
  55. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_batches.py +0 -0
  56. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_checkpoints.py +0 -0
  57. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_dispatching.py +0 -0
  58. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_durability.py +0 -0
  59. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_edit_dispatched.py +0 -0
  60. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_fenced_postgres.py +0 -0
  61. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_input_required.py +0 -0
  62. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_interest.py +0 -0
  63. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_langgraph.py +0 -0
  64. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_linear_thread.py +0 -0
  65. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_meta.py +0 -0
  66. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_mint_concurrency.py +0 -0
  67. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_outcomes.py +0 -0
  68. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_placement.py +0 -0
  69. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_prepare_hooks.py +0 -0
  70. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_restore.py +0 -0
  71. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_rewind_during_run.py +0 -0
  72. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_admission.py +0 -0
  73. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_crash_sweep.py +0 -0
  74. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_eviction.py +0 -0
  75. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_ownership.py +0 -0
  76. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_sync.py +0 -0
  77. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_settle.py +0 -0
  78. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_stop_continue.py +0 -0
  79. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_stopping_window.py +0 -0
  80. {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_undo.py +0 -0
@@ -2,6 +2,7 @@ node_modules
2
2
  .next
3
3
  dist
4
4
  dist-test
5
+ coverage
5
6
  .wrangler
6
7
  .DS_Store
7
8
  .env
@@ -28,3 +29,10 @@ apps/docs/.docs
28
29
 
29
30
  MAINTAIN_EPOCH.md
30
31
  DOCS_EPOCH.md
32
+
33
+ # terraform
34
+ .terraform/
35
+ *.tfstate
36
+ *.tfstate.*
37
+ *.tfvars
38
+ !*.tfvars.example
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.37.1
3
+ Version: 0.39.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.16,>=0.15.2
9
- Requires-Dist: statewire<0.19,>=0.18.1
8
+ Requires-Dist: pinned<0.17,>=0.16.0
9
+ Requires-Dist: statewire<0.23,>=0.21.1
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.1"
3
+ version = "0.39.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.1,<0.19", "pinned>=0.15.2,<0.16"]
8
+ dependencies = ["statewire>=0.21.1,<0.23", "pinned>=0.16.0,<0.17"]
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",
@@ -22,21 +22,21 @@ from .parts import PartsAssembler
22
22
  from .linear_thread import linear_thread
23
23
  from .run_manager import RunManager, RunManagerDescriptor, run_manager
24
24
  from .threads import DuplicateThread, HarnessThreads, MemoryThreadStore, ThreadRecord, ThreadStatus, ThreadStore, UnknownThread
25
- from .wire import CONTEXT, INTEREST, HarnessMessages
25
+ from .wire import INTEREST, HarnessMessages
26
26
  from .voice import (
27
27
  VoiceManager,
28
28
  VoiceManagerDescriptor,
29
29
  VoiceProvider,
30
30
  VoiceTurnCommitDeferred,
31
31
  end_voice,
32
+ set_voice_mic,
32
33
  start_voice,
33
- temp_frontend_tool_result,
34
34
  voice_client_departed,
35
35
  voice_manager,
36
36
  )
37
+ from .voice_thread import VoiceThread
37
38
 
38
39
  __all__ = [
39
- "CONTEXT",
40
40
  "HARNESS_PROTOCOL",
41
41
  "HARNESS_THREADS_PROTOCOL",
42
42
  "HarnessThreads",
@@ -66,6 +66,7 @@ __all__ = [
66
66
  "VoiceManager",
67
67
  "VoiceManagerDescriptor",
68
68
  "VoiceProvider",
69
+ "VoiceThread",
69
70
  "VoiceTurnCommitDeferred",
70
71
  "checkpoint_thread",
71
72
  "check_request",
@@ -80,8 +81,8 @@ __all__ = [
80
81
  "linear_thread",
81
82
  "resolve",
82
83
  "run_manager",
84
+ "set_voice_mic",
83
85
  "start_voice",
84
- "temp_frontend_tool_result",
85
86
  "voice_client_departed",
86
87
  "voice_manager",
87
88
  ]
@@ -2,12 +2,15 @@
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
9
11
 
10
12
  from statepatch import plain
13
+ from statewire import StatewireReject
11
14
 
12
15
  from .checkpoints import (
13
16
  CheckpointIndex,
@@ -17,6 +20,7 @@ from .checkpoints import (
17
20
  )
18
21
  from .messages import MAIN_NS, MessageTree
19
22
  from .parts import PartsAssembler, text_of
23
+ from .voice_thread import upsert_voice_turn
20
24
 
21
25
  _TYPE_TO_ROLE = {"human": "user", "ai": "assistant", "tool": "assistant", "system": "assistant"}
22
26
 
@@ -29,8 +33,45 @@ def deepagents_role(message: dict[str, Any]) -> str:
29
33
  raise ValueError(f"unsupported message type: {message['type']!r}") from exc
30
34
 
31
35
 
32
- def _user_text(message: dict[str, Any]) -> str:
33
- return "\n\n".join(part["text"] for part in message["parts"] if part["type"] == "text")
36
+ def _file_block(part: dict[str, Any]) -> dict[str, Any]:
37
+ url, media_type = part["url"], part["mediaType"]
38
+ if url.startswith("data:"):
39
+ header, comma, data = url.removeprefix("data:").partition(",")
40
+ media_type, *params = header.split(";")
41
+ if not comma or not media_type or params[-1:] != ["base64"]:
42
+ raise ValueError("file data URLs must be data:<type>;base64,<data>")
43
+ source = {"base64": data, "mime_type": media_type}
44
+ else:
45
+ raise ValueError("file URLs must be inline data:<type>;base64,<data> URLs")
46
+ kind = media_type.split("/", 1)[0]
47
+ block = {"type": kind if kind in ("image", "audio", "video") else "file", **source}
48
+ if "filename" in part:
49
+ block["extras"] = {"filename": part["filename"]}
50
+ return block
51
+
52
+
53
+ def _user_content(message: dict[str, Any]) -> str | list[dict[str, Any]]:
54
+ parts = message["parts"]
55
+ if all(part["type"] == "text" for part in parts):
56
+ return "\n\n".join(part["text"] for part in parts)
57
+ return [{"type": "text", "text": part["text"]} if part["type"] == "text" else _file_block(part) for part in parts]
58
+
59
+
60
+ def check_file_parts(message: dict[str, Any]) -> dict[str, Any]:
61
+ """``prepare_message`` hook: a file part ``_file_block`` cannot carry rejects ``invalid-message``."""
62
+ parts = message.get("parts")
63
+ for part in parts if isinstance(parts, list) else ():
64
+ if (
65
+ isinstance(part, dict)
66
+ and part.get("type") == "file"
67
+ and isinstance(part.get("url"), str)
68
+ and isinstance(part.get("mediaType"), str)
69
+ ):
70
+ try:
71
+ _file_block(part)
72
+ except ValueError as exc:
73
+ raise StatewireReject(str(exc), payload={"reason": "invalid-message"}) from exc
74
+ return message
34
75
 
35
76
 
36
77
  def _ns_of(namespace: tuple[str, ...]) -> str:
@@ -41,6 +82,10 @@ def _checkpoint_ns(ns: str) -> str:
41
82
  return "" if ns == MAIN_NS else ns
42
83
 
43
84
 
85
+ def _root_of(ns: str) -> str:
86
+ return ns.split("|", 1)[0]
87
+
88
+
44
89
  class DeepagentsThread:
45
90
  """One thread's harness messages over a compiled deepagents graph: boot, turn planning, stream folding, ingest, subagents."""
46
91
 
@@ -67,7 +112,7 @@ class DeepagentsThread:
67
112
  self._input_checkpoint_seen = False
68
113
  self._assemblers: dict[str, PartsAssembler] = {}
69
114
  self._touched: set[tuple[str, str]] = set()
70
- self._pending_subagents: dict[str, str] = {}
115
+ self._subagents: set[str] = set()
71
116
  self.interrupt: dict[str, Any] | None = None
72
117
 
73
118
  async def boot(self) -> None:
@@ -84,7 +129,9 @@ class DeepagentsThread:
84
129
  thread.head = max(thread.messages.values(), key=lambda m: m["seq"])["id"]
85
130
  self._thread_state(ns)
86
131
  if self._thread.transcript:
87
- self.tree.set_head(MAIN_NS, self._harness_id(self._thread.transcript[-1]["id"]))
132
+ # voice turns commit after the run they preceded: the checkpoint tail may sit above the tree leaf
133
+ last = self._harness_id(self._thread.transcript[-1]["id"])
134
+ self.tree.set_head(MAIN_NS, self.tree.thread(MAIN_NS).leaf_of(last))
88
135
  else:
89
136
  self.tree.thread(MAIN_NS)
90
137
  self._thread_state(MAIN_NS)
@@ -105,18 +152,7 @@ class DeepagentsThread:
105
152
  self._threads[ns] = {**current, **fields, "headId": thread.head}
106
153
 
107
154
  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
- }
155
+ return self.tree.meta(MAIN_NS, message_id)
120
156
 
121
157
  def config(self) -> dict[str, Any]:
122
158
  return {"configurable": {"thread_id": self._thread_id}}
@@ -125,19 +161,25 @@ class DeepagentsThread:
125
161
  """The dispatch's langgraph (payload, config): forks at the anchor when the batch does not extend the head."""
126
162
  self.abandon_turn()
127
163
  config = self.config()
128
- if ctx.resume:
129
- return None, config
130
164
  thread = self.tree.thread(MAIN_NS)
131
165
  if ctx.trigger == "input-resume":
132
166
  from langgraph.types import Command
133
167
 
168
+ tool_ids = {p.get("toolInvocationId") for p in plain(self.tree.get(MAIN_NS, thread.head)["parts"])}
169
+ missing = [r["toolCallId"] for r, _response, _meta in ctx.input_outcomes if r["toolCallId"] not in tool_ids]
170
+ if missing:
171
+ raise KeyError(f"head {thread.head!r} holds no tool part for {missing!r}")
172
+ self._mount(MAIN_NS, thread.head)
173
+ if ctx.resume:
174
+ return None, config
134
175
  decisions = [
135
176
  {"type": "approve"} if response and response.get("decision") == "approve" else {"type": "reject"}
136
177
  for _request, response, _meta in ctx.input_outcomes
137
178
  ]
138
179
  self._admission = ctx.applied()
139
- self._assemblers.pop(MAIN_NS, None)
140
180
  return Command(resume={"decisions": decisions}), config
181
+ if ctx.resume:
182
+ return None, config
141
183
  if ctx.has_rollback:
142
184
  parent = ctx.rollback_to
143
185
  elif ctx.messages:
@@ -158,7 +200,7 @@ class DeepagentsThread:
158
200
 
159
201
  turn: list[Any] = [*plan.replay]
160
202
  for message in ctx.messages:
161
- turn.append(HumanMessage(id=message["id"], content=_user_text(message)))
203
+ turn.append(HumanMessage(id=message["id"], content=_user_content(message)))
162
204
  self._append_user(message, parent)
163
205
  parent = message["id"]
164
206
  self.tree.set_head(MAIN_NS, parent)
@@ -198,7 +240,7 @@ class DeepagentsThread:
198
240
  turn = []
199
241
  for message in messages:
200
242
  self._append_user(message, thread.head)
201
- turn.append(HumanMessage(id=message["id"], content=_user_text(message)))
243
+ turn.append(HumanMessage(id=message["id"], content=_user_content(message)))
202
244
  self._thread_state(MAIN_NS)
203
245
  self._assemblers.pop(MAIN_NS, None)
204
246
  self._changed()
@@ -235,10 +277,14 @@ class DeepagentsThread:
235
277
  "metadata": {"provider": {"ids": []}},
236
278
  }
237
279
  self.tree.add(ns, value)
238
- self._touched.add((ns, message_id))
239
- assembler = self._assemblers[ns] = PartsAssembler(lambda: self.tree.get(ns, message_id))
240
- self._thread_state(ns, status="streaming")
241
- self._changed()
280
+ assembler = self._mount(ns, message_id)
281
+ return assembler
282
+
283
+ def _mount(self, ns: str, message_id: str) -> PartsAssembler:
284
+ self._touched.add((ns, message_id))
285
+ assembler = self._assemblers[ns] = PartsAssembler(lambda: self.tree.get(ns, message_id))
286
+ self._thread_state(ns, status="streaming")
287
+ self._changed()
242
288
  return assembler
243
289
 
244
290
  def _fold_id(self, assembler: PartsAssembler, langchain_id: str | None) -> None:
@@ -248,8 +294,8 @@ class DeepagentsThread:
248
294
  if langchain_id not in plain(ids):
249
295
  ids.append(langchain_id)
250
296
 
251
- def _spawn(self, ns: str, description: str) -> None:
252
- main = self._assemblers.get(MAIN_NS)
297
+ def _spawn(self, parent: str, ns: str, description: str) -> None:
298
+ main = self._assemblers.get(parent)
253
299
  if main is None:
254
300
  return
255
301
  for part in plain(main.parts):
@@ -289,7 +335,7 @@ class DeepagentsThread:
289
335
  )
290
336
  self._touched.add((ns, first.id))
291
337
  self._thread_state(ns, status="streaming")
292
- self._spawn(ns, text_of(first.content))
338
+ self._spawn(_ns_of(namespace[:-1]), ns, text_of(first.content))
293
339
  self._changed()
294
340
  return
295
341
  if mode == "messages":
@@ -303,8 +349,8 @@ class DeepagentsThread:
303
349
  assembler = self._assemblers.get(ns)
304
350
  if assembler is not None:
305
351
  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:
352
+ sub_ns = _ns_of((*namespace, *meta.get("langgraph_checkpoint_ns", "").split("|")))
353
+ if sub_ns in self.tree.threads:
308
354
  assembler.amend(message.tool_call_id, metadata={"ns": sub_ns})
309
355
  self._finish(sub_ns)
310
356
  assembler.tool_result(message)
@@ -319,14 +365,16 @@ class DeepagentsThread:
319
365
  self.interrupt = {"toolCallId": tool_id}
320
366
  self._changed()
321
367
 
322
- def _finish(self, ns: str) -> None:
368
+ def _finish(self, ns: str, outcome: dict[str, Any] | None = None) -> None:
323
369
  assembler = self._assemblers.pop(ns, None)
324
370
  if assembler is not None:
325
371
  assembler.finish()
372
+ for key, value in (outcome or {}).items():
373
+ assembler.message[key] = value
326
374
  self._thread_state(ns, status="idle")
327
375
 
328
- async def ingest_turn(self, *, refetch: bool = False) -> None:
329
- """Ingest the turn's captured root checkpoint, persist the touched harness messages, and settle streaming state."""
376
+ async def ingest_turn(self, *, refetch: bool = False, outcome: dict[str, Any] | None = None) -> None:
377
+ """Ingest the turn's captured root checkpoint, persist the touched harness messages, and settle streaming state; ``outcome`` lands on the turn's open replies."""
330
378
  captured, self._captured = self._captured, None
331
379
  if refetch:
332
380
  await self._thread.ingest_latest()
@@ -336,15 +384,53 @@ class DeepagentsThread:
336
384
  await self._thread.ingest(langgraph_snapshot(captured))
337
385
  if self._input_checkpoint_seen:
338
386
  self._confirm_admission()
339
- for ns in list(self._assemblers):
387
+ for ns in self._turn_namespaces():
340
388
  if self.interrupt is None or ns != MAIN_NS:
341
- self._finish(ns)
389
+ self._finish(ns, outcome)
342
390
  for ns in self.tree.threads:
343
- if ns != MAIN_NS:
391
+ if ns != MAIN_NS and _root_of(ns) not in self._subagents:
344
392
  self._thread_state(ns, status="idle")
345
393
  await self._persist()
346
394
  self._changed()
347
395
 
396
+ def _turn_namespaces(self) -> list[str]:
397
+ return [ns for ns in self._assemblers if _root_of(ns) not in self._subagents]
398
+
399
+ async def run_subagent(self, ns: str, message: dict[str, Any]) -> None:
400
+ """Run a harness user ``message`` as a turn in subagent namespace ``ns``: its reply streams into the tree and persists under ``checkpoint_ns == ns``."""
401
+ from langchain_core.messages import HumanMessage
402
+
403
+ if ns == MAIN_NS or "|" in ns:
404
+ raise ValueError(f"subagent namespace must be a root namespace other than {MAIN_NS!r}, got {ns!r}")
405
+ if ns in self._subagents:
406
+ raise RuntimeError(f"subagent {ns!r} is already running")
407
+ config = {"configurable": {"thread_id": f"{self._thread_id}/{ns}"}}
408
+ payload = {"messages": [HumanMessage(id=message["id"], content=_user_content(message))]}
409
+ thread = self.tree.thread(ns)
410
+ self.tree.add(ns, {**message, "parentId": thread.head})
411
+ self._touched.add((ns, message["id"]))
412
+ self._thread_state(ns, status="streaming")
413
+ self._subagents.add(ns)
414
+ self._changed()
415
+ try:
416
+ async for namespace, mode, event in self._agent.astream(
417
+ payload, config=config, stream_mode=["messages", "updates", "checkpoints"], subgraphs=True
418
+ ):
419
+ self.append_event((ns, *namespace), mode, event)
420
+ finally:
421
+ self._subagents.discard(ns)
422
+ for sub in [sub for sub in self._assemblers if _root_of(sub) == ns]:
423
+ self._finish(sub)
424
+ for sub in [sub for sub in self.tree.threads if _root_of(sub) == ns]:
425
+ self._thread_state(sub, status="idle")
426
+ await self._persist()
427
+ self._changed()
428
+
429
+ async def persist_message(self, ns: str, message_id: str) -> None:
430
+ """Write one tree message's index row."""
431
+ self._touched.add((ns, message_id))
432
+ await self._persist()
433
+
348
434
  async def _persist(self) -> None:
349
435
  touched, self._touched = self._touched, set()
350
436
  rows = [
@@ -361,14 +447,38 @@ class DeepagentsThread:
361
447
  if rows:
362
448
  await self._index.upsert_messages(rows)
363
449
 
364
- def abandon_turn(self) -> None:
365
- """Discard a stream capture and any fork planned for the abandoned turn."""
450
+ async def append_turns(self, turns: list[dict[str, Any]]) -> None:
451
+ """Commit ``{id, role, text}`` voice turns: one state update on the main tip carries them as messages and the tree entries turn durable."""
452
+ from langchain_core.messages import AIMessage, HumanMessage
453
+
454
+ if not turns:
455
+ raise ValueError("turns must be non-empty")
456
+ entries = [
457
+ (HumanMessage if turn["role"] == "user" else AIMessage)(id=turn["id"], content=turn["text"])
458
+ for turn in turns
459
+ ]
460
+ await self._agent.aupdate_state(self.config(), {"messages": entries}, as_node="__start__")
461
+ for turn in turns:
462
+ upsert_voice_turn(self.tree, turn, ephemeral=False)
463
+ self._touched.add((MAIN_NS, turn["id"]))
464
+ await self._thread.ingest_latest()
465
+ self._thread_state(MAIN_NS)
466
+ await self._persist()
467
+ self._changed()
468
+
469
+ async def fail_turn(self, error: str) -> None:
470
+ """Abandon the turn with its open replies marked ``failed`` and persist the touched messages."""
471
+ self.abandon_turn({"status": "failed", "error": error})
472
+ await self._persist()
473
+
474
+ def abandon_turn(self, outcome: dict[str, Any] | None = None) -> None:
475
+ """Discard a stream capture and any fork planned for the abandoned turn; ``outcome`` lands on the turn's open replies."""
366
476
  self._captured = None
367
477
  self._admission = None
368
478
  self._input_checkpoint_seen = False
369
479
  self.interrupt = None
370
- for ns in list(self._assemblers):
371
- self._finish(ns)
480
+ for ns in self._turn_namespaces():
481
+ self._finish(ns, outcome)
372
482
  self._thread.abandon_plan()
373
483
 
374
484
 
@@ -1,52 +1,49 @@
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
- from .deepagents import DeepagentsThread
14
- from .langgraph import FrontierCheckpointer
15
- from .protocol import HARNESS_PROTOCOL
17
+ from .deepagents import DeepagentsThread, check_file_parts
16
18
  from .messages import MAIN_NS
17
- from .run_manager import RunManager, run_manager
19
+ from .protocol import HARNESS_PROTOCOL
20
+ from .run_manager import RunManager, error_of, 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
18
23
  from .wire import HarnessMessages
19
24
 
20
- TITLE_LENGTH = 80
25
+ logger = logging.getLogger(__name__)
26
+ _HALTED = ("stopped", "error")
21
27
 
22
28
 
23
- def first_user_text(tree: Any) -> str | None:
24
- """Text of the first user message on the main head chain, cut to ``TITLE_LENGTH``."""
25
- thread = tree.threads.get(MAIN_NS)
26
- if thread is None or thread.head is None:
27
- return None
28
- for message_id in thread.chain(thread.head):
29
- message = thread.messages[message_id]
30
- if message["role"] != "user":
31
- continue
32
- text = " ".join(p["text"] for p in message["parts"] if p.get("type") == "text").strip()
33
- return text[:TITLE_LENGTH] if text else None
34
- return None
29
+ def _interrupted(ctx: RunManager.RunContext) -> dict[str, Any]:
30
+ reason = ctx.stop_reason
31
+ return {"status": "interrupted"} if reason is None else {"status": "interrupted", "stopReason": reason}
35
32
 
36
33
 
37
- class DeepagentsHost(HarnessMessages, Statewire):
34
+ class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
38
35
  """One thread: ``build_agent(checkpointer)`` returns the compiled deepagents graph."""
39
36
 
40
37
  protocols = (HARNESS_PROTOCOL,)
41
38
  checkpointer: Any = None
42
39
  index: CheckpointIndex | None = None
43
- #: Path of the ``HarnessThreads`` instance this thread lists itself on, e.g. ``"/thread-lists/default"``. Alpha.
44
- thread_list: str | None = None
45
40
 
46
41
  def build_agent(self, checkpointer: Any) -> Any:
47
42
  raise NotImplementedError(f"{type(self).__name__}.build_agent")
48
43
 
49
44
  async def lifespan(self):
45
+ from .langgraph import FrontierCheckpointer
46
+
50
47
  if type(self).checkpointer is None or type(self).index is None:
51
48
  raise RuntimeError(f"{type(self).__name__}.checkpointer and .index must be assigned before serving")
52
49
  checkpointer = FrontierCheckpointer(
@@ -65,22 +62,92 @@ class DeepagentsHost(HarnessMessages, Statewire):
65
62
  changed=self.refresh_messages,
66
63
  )
67
64
  await self.messages.boot()
65
+ self._voice_journal: dict[str, dict[str, Any]] = {}
68
66
  yield
69
67
 
70
68
  async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
71
69
  return await self.messages.get_message_meta(message_id)
72
70
 
73
- @run_manager(capabilities=("rewind",))
71
+ # ─── Voice ──────────────────────────────────────────────
72
+
73
+ def _runs_quiet(self) -> bool:
74
+ live = self.run.live()
75
+ return live is None or live.status.value in ("ready", *_HALTED)
76
+
77
+ async def commit_voice_turns(self, turns: list[dict[str, Any]]) -> None:
78
+ for turn in turns:
79
+ message = self.tree.get(MAIN_NS, turn["id"])
80
+ if message is None or is_ephemeral(message):
81
+ self._voice_journal[turn["id"]] = turn
82
+ if not self._runs_quiet():
83
+ raise VoiceTurnCommitDeferred(f"run {self.state['status']}")
84
+ await self._commit_voice_journal()
85
+
86
+ async def _commit_voice_journal(self) -> None:
87
+ turns, self._voice_journal = list(self._voice_journal.values()), {}
88
+ if turns:
89
+ await self.messages.append_turns(turns)
90
+
91
+ async def _deliver_voice_journal(self) -> None:
92
+ # a run dispatched between the settle and this write would race the checkpoint; the journal keeps the turns until the next settle.
93
+ # a journaled turn keeps its tree position; the graph state appends it after the run's messages
94
+ if not self._runs_quiet():
95
+ return
96
+ try:
97
+ await self._commit_voice_journal()
98
+ except Exception:
99
+ logger.exception("voice turns failed to commit after the run settled")
100
+
101
+ def drop_ephemeral_message(self, message_id: str) -> None:
102
+ if message_id not in self._voice_journal:
103
+ super().drop_ephemeral_message(message_id)
104
+
105
+ async def run_subagent(self, ns: str, message: dict[str, Any]) -> None:
106
+ await self.messages.run_subagent(ns, message)
107
+
108
+ async def delegate(self, task_id: str, text: str) -> str:
109
+ try:
110
+ return await super().delegate(task_id, text)
111
+ finally:
112
+ await self.messages.persist_message(MAIN_NS, f"voicetask_{task_id}")
113
+
114
+ def run_status_changed(self, status: str) -> None:
115
+ super().run_status_changed(status)
116
+ if status in ("ready", *_HALTED) and self._voice_journal:
117
+ self.create_task(self._deliver_voice_journal())
118
+
119
+ @command("voice/start")
120
+ async def voice_start(self, params, *, ctx):
121
+ return await start_voice(self, params, ctx=ctx)
122
+
123
+ @command("voice/end")
124
+ async def voice_end(self, params=None, *, ctx):
125
+ return await end_voice(self, ctx=ctx)
126
+
127
+ @command("voice/mic")
128
+ async def voice_mic(self, params, *, ctx):
129
+ return await set_voice_mic(self, params, ctx=ctx)
130
+
131
+ def client_departed(self, client_id: str) -> None:
132
+ voice_client_departed(self, client_id)
133
+
134
+ # ─── Runs ───────────────────────────────────────────────
135
+
136
+ @run_manager(capabilities=("rewind", "files"), prepare_message=check_file_parts)
74
137
  async def run(self, ctx: RunManager.RunContext) -> Any:
75
138
  payload, config = await self.messages.plan_turn(ctx)
76
139
  while True:
77
140
  try:
78
141
  outcome = await self._stream_turn(ctx, payload, config)
79
- await self.messages.ingest_turn(refetch=outcome is not None)
142
+ await self.messages.ingest_turn(
143
+ refetch=outcome is not None, outcome=None if outcome is None else _interrupted(ctx)
144
+ )
145
+ except Exception as exc:
146
+ await self.messages.fail_turn(error_of(exc)["message"])
147
+ raise
80
148
  except BaseException:
81
- self.messages.abandon_turn()
149
+ self.messages.abandon_turn(_interrupted(ctx))
82
150
  raise
83
- await self._touch_thread_list()
84
151
  if outcome is not None:
85
152
  return outcome
86
153
  interrupt = self.messages.interrupt
@@ -95,17 +162,6 @@ class DeepagentsHost(HarnessMessages, Statewire):
95
162
  payload = {"messages": steered}
96
163
  config = self.messages.config()
97
164
 
98
- async def _touch_thread_list(self) -> None:
99
- if self.thread_list is None:
100
- return
101
- body: dict[str, Any] = {"threadId": self.id}
102
- title = first_user_text(self.tree)
103
- if title is not None:
104
- body["title"] = title
105
- response = await self.cluster.post(f"{self.thread_list}/touch", json=body)
106
- if response.status_code != 200:
107
- raise RuntimeError(f"{self.thread_list}/touch answered {response.status_code}: {response.text}")
108
-
109
165
  async def _stream_turn(self, ctx: RunManager.RunContext, payload: Any, config: dict[str, Any]) -> Any:
110
166
  stream = self.agent.astream(
111
167
  payload, config=config, stream_mode=["messages", "updates", "checkpoints"], subgraphs=True
@@ -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: