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.
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/.gitignore +8 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/PKG-INFO +6 -3
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/pyproject.toml +3 -2
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/__init__.py +5 -4
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents.py +151 -41
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents_host.py +92 -36
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/messages.py +29 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/parts.py +13 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/protocol.py +2 -3
- harness_sdk_python-0.39.0/src/harness_sdk/providers/__init__.py +1 -0
- harness_sdk_python-0.39.0/src/harness_sdk/providers/gpt_live.py +201 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/run_manager.py +101 -36
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/threads.py +27 -8
- harness_sdk_python-0.39.0/src/harness_sdk/voice.py +629 -0
- harness_sdk_python-0.39.0/src/harness_sdk/voice_thread.py +163 -0
- harness_sdk_python-0.39.0/src/harness_sdk/wire.py +209 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_admit_steer.py +34 -3
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_branch_anchor.py +46 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_approval.py +99 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_files.py +123 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_outcome.py +133 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_thread.py +336 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_voice.py +130 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_edit_reload.py +9 -7
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_enqueue.py +45 -0
- harness_sdk_python-0.39.0/tests/test_gpt_live.py +219 -0
- harness_sdk_python-0.39.0/tests/test_imports.py +24 -0
- harness_sdk_python-0.39.0/tests/test_parts.py +96 -0
- harness_sdk_python-0.39.0/tests/test_run_id_bounds.py +267 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_steer.py +44 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_threads.py +99 -10
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_voice.py +251 -208
- harness_sdk_python-0.39.0/tests/test_voice_delegation.py +175 -0
- harness_sdk_python-0.39.0/tests/test_voice_events.py +175 -0
- harness_sdk_python-0.39.0/tests/test_wire.py +293 -0
- harness_sdk_python-0.39.0/tests/voice_helpers.py +283 -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_deepagents_thread.py +0 -173
- harness_sdk_python-0.37.1/tests/test_parts.py +0 -49
- 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.39.0}/README.md +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/interest.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/langgraph.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_interest.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_langgraph.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_admission.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_run_sync.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.37.1 → harness_sdk_python-0.39.0}/tests/test_stopping_window.py +0 -0
- {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.
|
|
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.
|
|
9
|
-
Requires-Dist: statewire<0.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
365
|
-
"""
|
|
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
|
|
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 .
|
|
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
|
-
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
_HALTED = ("stopped", "error")
|
|
21
27
|
|
|
22
28
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|