harness-sdk-python 0.37.1__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.
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/.gitignore +7 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/PKG-INFO +5 -2
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/pyproject.toml +3 -2
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/__init__.py +5 -4
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/deepagents.py +78 -23
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/deepagents_host.py +79 -33
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/messages.py +29 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/protocol.py +2 -3
- harness_sdk_python-0.38.0/src/harness_sdk/providers/__init__.py +1 -0
- harness_sdk_python-0.38.0/src/harness_sdk/providers/gpt_live.py +201 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/run_manager.py +7 -1
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/threads.py +27 -8
- harness_sdk_python-0.38.0/src/harness_sdk/voice.py +562 -0
- harness_sdk_python-0.38.0/src/harness_sdk/voice_thread.py +154 -0
- harness_sdk_python-0.38.0/src/harness_sdk/wire.py +209 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_deepagents_thread.py +96 -0
- harness_sdk_python-0.38.0/tests/test_deepagents_voice.py +129 -0
- harness_sdk_python-0.38.0/tests/test_gpt_live.py +219 -0
- harness_sdk_python-0.38.0/tests/test_imports.py +24 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_threads.py +99 -10
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_voice.py +195 -193
- harness_sdk_python-0.38.0/tests/test_voice_delegation.py +160 -0
- harness_sdk_python-0.38.0/tests/test_wire.py +269 -0
- harness_sdk_python-0.38.0/tests/voice_helpers.py +279 -0
- harness_sdk_python-0.37.1/src/harness_sdk/voice.py +0 -805
- harness_sdk_python-0.37.1/src/harness_sdk/wire.py +0 -116
- harness_sdk_python-0.37.1/tests/test_voice_delegation.py +0 -327
- harness_sdk_python-0.37.1/tests/test_voice_frontend_tools.py +0 -214
- harness_sdk_python-0.37.1/tests/test_wire.py +0 -111
- harness_sdk_python-0.37.1/tests/voice_helpers.py +0 -234
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/README.md +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/interest.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/langgraph.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/src/harness_sdk/parts.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_interest.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_langgraph.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_parts.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_run_admission.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_run_sync.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.38.0}/tests/test_undo.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
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,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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
425
|
+
for ns in self._turn_namespaces():
|
|
371
426
|
self._finish(ns)
|
|
372
427
|
self._thread.abandon_plan()
|
|
373
428
|
|
|
@@ -1,52 +1,44 @@
|
|
|
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
|
|
15
|
-
from .protocol import HARNESS_PROTOCOL
|
|
16
18
|
from .messages import MAIN_NS
|
|
19
|
+
from .protocol import HARNESS_PROTOCOL
|
|
17
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
|
|
18
23
|
from .wire import HarnessMessages
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
_HALTED = ("stopped", "error")
|
|
22
27
|
|
|
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
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
class DeepagentsHost(HarnessMessages, Statewire):
|
|
29
|
+
class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
|
|
38
30
|
"""One thread: ``build_agent(checkpointer)`` returns the compiled deepagents graph."""
|
|
39
31
|
|
|
40
32
|
protocols = (HARNESS_PROTOCOL,)
|
|
41
33
|
checkpointer: Any = None
|
|
42
34
|
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
35
|
|
|
46
36
|
def build_agent(self, checkpointer: Any) -> Any:
|
|
47
37
|
raise NotImplementedError(f"{type(self).__name__}.build_agent")
|
|
48
38
|
|
|
49
39
|
async def lifespan(self):
|
|
40
|
+
from .langgraph import FrontierCheckpointer
|
|
41
|
+
|
|
50
42
|
if type(self).checkpointer is None or type(self).index is None:
|
|
51
43
|
raise RuntimeError(f"{type(self).__name__}.checkpointer and .index must be assigned before serving")
|
|
52
44
|
checkpointer = FrontierCheckpointer(
|
|
@@ -65,11 +57,77 @@ class DeepagentsHost(HarnessMessages, Statewire):
|
|
|
65
57
|
changed=self.refresh_messages,
|
|
66
58
|
)
|
|
67
59
|
await self.messages.boot()
|
|
60
|
+
self._voice_journal: dict[str, dict[str, Any]] = {}
|
|
68
61
|
yield
|
|
69
62
|
|
|
70
63
|
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
71
64
|
return await self.messages.get_message_meta(message_id)
|
|
72
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
|
+
|
|
73
131
|
@run_manager(capabilities=("rewind",))
|
|
74
132
|
async def run(self, ctx: RunManager.RunContext) -> Any:
|
|
75
133
|
payload, config = await self.messages.plan_turn(ctx)
|
|
@@ -80,7 +138,6 @@ class DeepagentsHost(HarnessMessages, Statewire):
|
|
|
80
138
|
except BaseException:
|
|
81
139
|
self.messages.abandon_turn()
|
|
82
140
|
raise
|
|
83
|
-
await self._touch_thread_list()
|
|
84
141
|
if outcome is not None:
|
|
85
142
|
return outcome
|
|
86
143
|
interrupt = self.messages.interrupt
|
|
@@ -95,17 +152,6 @@ class DeepagentsHost(HarnessMessages, Statewire):
|
|
|
95
152
|
payload = {"messages": steered}
|
|
96
153
|
config = self.messages.config()
|
|
97
154
|
|
|
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
155
|
async def _stream_turn(self, ctx: RunManager.RunContext, payload: Any, config: dict[str, Any]) -> Any:
|
|
110
156
|
stream = self.agent.astream(
|
|
111
157
|
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:
|
|
@@ -3,11 +3,10 @@ from statewire.protocol import Subprotocol
|
|
|
3
3
|
|
|
4
4
|
HARNESS_PROTOCOL = Statewire.Protocol(
|
|
5
5
|
"harness-sdk",
|
|
6
|
-
version="2026-09-
|
|
7
|
-
min_version="2026-09-
|
|
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
|
)
|
|
13
12
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Voice providers: ``VoiceProvider`` implementations over vendor APIs."""
|