harness-sdk-python 0.36.6__tar.gz → 0.37.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.36.6 → harness_sdk_python-0.37.0}/PKG-INFO +2 -2
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/pyproject.toml +2 -2
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/__init__.py +18 -13
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/checkpoints.py +63 -11
- harness_sdk_python-0.37.0/src/harness_sdk/deepagents.py +377 -0
- harness_sdk_python-0.37.0/src/harness_sdk/deepagents_host.py +133 -0
- harness_sdk_python-0.37.0/src/harness_sdk/interest.py +120 -0
- harness_sdk_python-0.37.0/src/harness_sdk/messages.py +116 -0
- harness_sdk_python-0.37.0/src/harness_sdk/parts.py +148 -0
- harness_sdk_python-0.37.0/src/harness_sdk/protocol.py +12 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/run_manager.py +4 -1
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/voice.py +11 -5
- harness_sdk_python-0.37.0/src/harness_sdk/wire.py +114 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/run_helpers.py +26 -29
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_admit_steer.py +8 -3
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_batches.py +5 -1
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_checkpoints.py +4 -2
- harness_sdk_python-0.37.0/tests/test_deepagents_thread.py +173 -0
- harness_sdk_python-0.37.0/tests/test_interest.py +102 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_outcomes.py +10 -4
- harness_sdk_python-0.37.0/tests/test_parts.py +49 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_settle.py +2 -4
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_stop_continue.py +1 -1
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_stopping_window.py +1 -1
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_undo.py +1 -1
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_voice.py +110 -67
- harness_sdk_python-0.37.0/tests/test_wire.py +108 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/voice_helpers.py +24 -5
- harness_sdk_python-0.36.6/src/harness_sdk/deepagents.py +0 -421
- harness_sdk_python-0.36.6/src/harness_sdk/protocol.py +0 -5
- harness_sdk_python-0.36.6/tests/test_deepagents_messages.py +0 -642
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/.gitignore +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/README.md +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/langgraph.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_langgraph.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_run_admission.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_run_sync.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.36.6 → harness_sdk_python-0.37.0}/tests/test_voice_frontend_tools.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.37.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.19,>=0.18.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'
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.37.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.18.0,<0.19", "pinned>=0.15.2,<0.16"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -14,16 +14,14 @@ from .checkpoints import (
|
|
|
14
14
|
langgraph_state_snapshot,
|
|
15
15
|
langgraph_switch_values,
|
|
16
16
|
)
|
|
17
|
-
from .deepagents import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
deepagents_role,
|
|
23
|
-
deepagents_upsert_ephemeral,
|
|
24
|
-
)
|
|
17
|
+
from .deepagents import DeepagentsThread, deepagents_role
|
|
18
|
+
from .deepagents_host import DeepagentsHost
|
|
19
|
+
from .interest import InterestError, check_request, covered, resolve
|
|
20
|
+
from .messages import MAIN_NS, MessageTree
|
|
21
|
+
from .parts import PartsAssembler
|
|
25
22
|
from .linear_thread import linear_thread
|
|
26
23
|
from .run_manager import RunManager, RunManagerDescriptor, run_manager
|
|
24
|
+
from .wire import CONTEXT, INTEREST, HarnessMessages
|
|
27
25
|
from .voice import (
|
|
28
26
|
VoiceManager,
|
|
29
27
|
VoiceManagerDescriptor,
|
|
@@ -37,11 +35,19 @@ from .voice import (
|
|
|
37
35
|
)
|
|
38
36
|
|
|
39
37
|
__all__ = [
|
|
38
|
+
"CONTEXT",
|
|
40
39
|
"HARNESS_PROTOCOL",
|
|
40
|
+
"HarnessMessages",
|
|
41
|
+
"INTEREST",
|
|
41
42
|
"CheckpointIndex",
|
|
42
43
|
"CheckpointStore",
|
|
43
44
|
"CheckpointThread",
|
|
44
|
-
"
|
|
45
|
+
"DeepagentsHost",
|
|
46
|
+
"DeepagentsThread",
|
|
47
|
+
"InterestError",
|
|
48
|
+
"MAIN_NS",
|
|
49
|
+
"MessageTree",
|
|
50
|
+
"PartsAssembler",
|
|
45
51
|
"MemoryCheckpointIndex",
|
|
46
52
|
"Plan",
|
|
47
53
|
"PostgresCheckpointIndex",
|
|
@@ -53,11 +59,9 @@ __all__ = [
|
|
|
53
59
|
"VoiceProvider",
|
|
54
60
|
"VoiceTurnCommitDeferred",
|
|
55
61
|
"checkpoint_thread",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"deepagents_restamp",
|
|
62
|
+
"check_request",
|
|
63
|
+
"covered",
|
|
59
64
|
"deepagents_role",
|
|
60
|
-
"deepagents_upsert_ephemeral",
|
|
61
65
|
"end_voice",
|
|
62
66
|
"langgraph_checkpoints",
|
|
63
67
|
"langgraph_checkpointer",
|
|
@@ -65,6 +69,7 @@ __all__ = [
|
|
|
65
69
|
"langgraph_state_snapshot",
|
|
66
70
|
"langgraph_switch_values",
|
|
67
71
|
"linear_thread",
|
|
72
|
+
"resolve",
|
|
68
73
|
"run_manager",
|
|
69
74
|
"start_voice",
|
|
70
75
|
"temp_frontend_tool_result",
|
|
@@ -29,6 +29,8 @@ _TIP_TRIGGERS = (
|
|
|
29
29
|
)
|
|
30
30
|
_UNREAD: Any = object()
|
|
31
31
|
|
|
32
|
+
_VALUE_KEYS = ("parent_id", "seq", "value")
|
|
33
|
+
|
|
32
34
|
|
|
33
35
|
@dataclass(frozen=True)
|
|
34
36
|
class Snapshot:
|
|
@@ -81,12 +83,18 @@ class CheckpointIndex(Protocol):
|
|
|
81
83
|
checkpoint_id: str,
|
|
82
84
|
) -> None: ...
|
|
83
85
|
|
|
84
|
-
async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
|
|
86
|
+
async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
|
|
87
|
+
"""Insert rows; ``anchor_checkpoint_id`` and ``branch_id`` are set once, ``parent_id``/``seq``/``value`` replace on conflict when present."""
|
|
88
|
+
...
|
|
85
89
|
|
|
86
90
|
async def get_message(
|
|
87
91
|
self, thread_id: str, checkpoint_ns: str, message_id: str
|
|
88
92
|
) -> dict[str, Any] | None: ...
|
|
89
93
|
|
|
94
|
+
async def read_messages(self, thread_id: str) -> list[dict[str, Any]]:
|
|
95
|
+
"""Every row of the thread carrying a ``value``, across namespaces, ordered by ``seq``."""
|
|
96
|
+
...
|
|
97
|
+
|
|
90
98
|
|
|
91
99
|
def _check_branch_ids(branch_ids: list[int], count: int) -> None:
|
|
92
100
|
for branch_id in branch_ids:
|
|
@@ -144,13 +152,17 @@ class MemoryCheckpointIndex:
|
|
|
144
152
|
async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
|
|
145
153
|
for row in rows:
|
|
146
154
|
key = (row["thread_id"], row["checkpoint_ns"], row["message_id"])
|
|
147
|
-
self._messages.setdefault(
|
|
155
|
+
stored = self._messages.setdefault(
|
|
148
156
|
key,
|
|
149
157
|
{
|
|
150
|
-
"anchor_checkpoint_id": row
|
|
151
|
-
"branch_id": row
|
|
158
|
+
"anchor_checkpoint_id": row.get("anchor_checkpoint_id"),
|
|
159
|
+
"branch_id": row.get("branch_id"),
|
|
160
|
+
"parent_id": None,
|
|
161
|
+
"seq": None,
|
|
162
|
+
"value": None,
|
|
152
163
|
},
|
|
153
164
|
)
|
|
165
|
+
stored.update({k: row[k] for k in _VALUE_KEYS if k in row})
|
|
154
166
|
|
|
155
167
|
async def get_message(
|
|
156
168
|
self, thread_id: str, checkpoint_ns: str, message_id: str
|
|
@@ -158,6 +170,14 @@ class MemoryCheckpointIndex:
|
|
|
158
170
|
row = self._messages.get((thread_id, checkpoint_ns, message_id))
|
|
159
171
|
return dict(row) if row is not None else None
|
|
160
172
|
|
|
173
|
+
async def read_messages(self, thread_id: str) -> list[dict[str, Any]]:
|
|
174
|
+
rows = [
|
|
175
|
+
{"checkpoint_ns": ns, "message_id": mid, **row}
|
|
176
|
+
for (tid, ns, mid), row in self._messages.items()
|
|
177
|
+
if tid == thread_id and row["value"] is not None
|
|
178
|
+
]
|
|
179
|
+
return sorted(rows, key=lambda r: (r["checkpoint_ns"], r["seq"]))
|
|
180
|
+
|
|
161
181
|
|
|
162
182
|
class PostgresCheckpointIndex:
|
|
163
183
|
"""psycopg-backed CheckpointIndex over an ``AsyncConnection`` or ``AsyncConnectionPool``; ``setup()`` creates the two tables."""
|
|
@@ -204,7 +224,10 @@ class PostgresCheckpointIndex:
|
|
|
204
224
|
" checkpoint_ns TEXT NOT NULL,"
|
|
205
225
|
" message_id TEXT NOT NULL,"
|
|
206
226
|
" anchor_checkpoint_id TEXT,"
|
|
207
|
-
" branch_id INTEGER
|
|
227
|
+
" branch_id INTEGER,"
|
|
228
|
+
" parent_id TEXT,"
|
|
229
|
+
" seq INTEGER,"
|
|
230
|
+
" value JSONB,"
|
|
208
231
|
" PRIMARY KEY (thread_id, checkpoint_ns, message_id))"
|
|
209
232
|
)
|
|
210
233
|
|
|
@@ -281,14 +304,30 @@ class PostgresCheckpointIndex:
|
|
|
281
304
|
)
|
|
282
305
|
|
|
283
306
|
async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
|
|
307
|
+
from psycopg.types.json import Jsonb
|
|
308
|
+
|
|
284
309
|
async with self._connection() as conn, conn.cursor() as cur:
|
|
285
310
|
await cur.executemany(
|
|
286
311
|
"INSERT INTO checkpoint_messages"
|
|
287
|
-
" (thread_id, checkpoint_ns, message_id, anchor_checkpoint_id, branch_id
|
|
312
|
+
" (thread_id, checkpoint_ns, message_id, anchor_checkpoint_id, branch_id,"
|
|
313
|
+
" parent_id, seq, value)"
|
|
288
314
|
" VALUES (%(thread_id)s, %(checkpoint_ns)s, %(message_id)s,"
|
|
289
|
-
" %(anchor_checkpoint_id)s, %(branch_id)s)"
|
|
290
|
-
" ON CONFLICT (thread_id, checkpoint_ns, message_id) DO
|
|
291
|
-
|
|
315
|
+
" %(anchor_checkpoint_id)s, %(branch_id)s, %(parent_id)s, %(seq)s, %(value)s)"
|
|
316
|
+
" ON CONFLICT (thread_id, checkpoint_ns, message_id) DO UPDATE SET"
|
|
317
|
+
" parent_id = COALESCE(EXCLUDED.parent_id, checkpoint_messages.parent_id),"
|
|
318
|
+
" seq = COALESCE(EXCLUDED.seq, checkpoint_messages.seq),"
|
|
319
|
+
" value = COALESCE(EXCLUDED.value, checkpoint_messages.value)",
|
|
320
|
+
[
|
|
321
|
+
{
|
|
322
|
+
"anchor_checkpoint_id": None,
|
|
323
|
+
"branch_id": None,
|
|
324
|
+
"parent_id": None,
|
|
325
|
+
"seq": None,
|
|
326
|
+
**row,
|
|
327
|
+
"value": Jsonb(row["value"]) if row.get("value") is not None else None,
|
|
328
|
+
}
|
|
329
|
+
for row in rows
|
|
330
|
+
],
|
|
292
331
|
)
|
|
293
332
|
|
|
294
333
|
async def get_message(
|
|
@@ -296,14 +335,27 @@ class PostgresCheckpointIndex:
|
|
|
296
335
|
) -> dict[str, Any] | None:
|
|
297
336
|
async with self._connection() as conn:
|
|
298
337
|
cur = await conn.execute(
|
|
299
|
-
"SELECT anchor_checkpoint_id, branch_id
|
|
338
|
+
"SELECT anchor_checkpoint_id, branch_id, parent_id, seq, value"
|
|
339
|
+
" FROM checkpoint_messages"
|
|
300
340
|
" WHERE thread_id = %s AND checkpoint_ns = %s AND message_id = %s",
|
|
301
341
|
(thread_id, checkpoint_ns, message_id),
|
|
302
342
|
)
|
|
303
343
|
row = await cur.fetchone()
|
|
304
344
|
if row is None:
|
|
305
345
|
return None
|
|
306
|
-
return
|
|
346
|
+
return dict(zip(("anchor_checkpoint_id", "branch_id", "parent_id", "seq", "value"), row))
|
|
347
|
+
|
|
348
|
+
async def read_messages(self, thread_id: str) -> list[dict[str, Any]]:
|
|
349
|
+
async with self._connection() as conn:
|
|
350
|
+
cur = await conn.execute(
|
|
351
|
+
"SELECT checkpoint_ns, message_id, anchor_checkpoint_id, branch_id, parent_id, seq, value"
|
|
352
|
+
" FROM checkpoint_messages WHERE thread_id = %s AND value IS NOT NULL"
|
|
353
|
+
" ORDER BY checkpoint_ns, seq",
|
|
354
|
+
(thread_id,),
|
|
355
|
+
)
|
|
356
|
+
rows = await cur.fetchall()
|
|
357
|
+
keys = ("checkpoint_ns", "message_id", "anchor_checkpoint_id", "branch_id", "parent_id", "seq", "value")
|
|
358
|
+
return [dict(zip(keys, row)) for row in rows]
|
|
307
359
|
|
|
308
360
|
|
|
309
361
|
def _message_dict(message: Any) -> dict[str, Any]:
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""DeepagentsThread: harness message trees over a deepagents graph. The
|
|
2
|
+
langgraph checkpointer is the agent's resumption source; the checkpoint
|
|
3
|
+
index rows carry the harness messages (``value``) the wire projects. A
|
|
4
|
+
rewind forks the graph at the anchor message's checkpoint; a send off the
|
|
5
|
+
head chain forks the same way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from statepatch import plain
|
|
11
|
+
|
|
12
|
+
from .checkpoints import (
|
|
13
|
+
CheckpointIndex,
|
|
14
|
+
checkpoint_thread,
|
|
15
|
+
langgraph_checkpoints,
|
|
16
|
+
langgraph_snapshot,
|
|
17
|
+
)
|
|
18
|
+
from .messages import MAIN_NS, MessageTree
|
|
19
|
+
from .parts import PartsAssembler, text_of
|
|
20
|
+
|
|
21
|
+
_TYPE_TO_ROLE = {"human": "user", "ai": "assistant", "tool": "assistant", "system": "assistant"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def deepagents_role(message: dict[str, Any]) -> str:
|
|
25
|
+
"""The harness role of a langgraph message dump."""
|
|
26
|
+
try:
|
|
27
|
+
return _TYPE_TO_ROLE[message["type"]]
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
raise ValueError(f"unsupported message type: {message['type']!r}") from exc
|
|
30
|
+
|
|
31
|
+
|
|
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")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ns_of(namespace: tuple[str, ...]) -> str:
|
|
37
|
+
return MAIN_NS if not namespace else "|".join(namespace)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _checkpoint_ns(ns: str) -> str:
|
|
41
|
+
return "" if ns == MAIN_NS else ns
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DeepagentsThread:
|
|
45
|
+
"""One thread's harness messages over a compiled deepagents graph: boot, turn planning, stream folding, ingest, subagents."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
agent: Any,
|
|
50
|
+
*,
|
|
51
|
+
index: CheckpointIndex,
|
|
52
|
+
thread_id: str,
|
|
53
|
+
tree: MessageTree,
|
|
54
|
+
threads: Any,
|
|
55
|
+
changed: Callable[[], None] = lambda: None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if not callable(getattr(agent, "aget_state", None)):
|
|
58
|
+
raise TypeError(f"agent must be a compiled langgraph graph, got {agent!r}")
|
|
59
|
+
self._agent = agent
|
|
60
|
+
self._index = index
|
|
61
|
+
self._thread_id = thread_id
|
|
62
|
+
self.tree = tree
|
|
63
|
+
self._threads = threads
|
|
64
|
+
self._changed = changed
|
|
65
|
+
self._captured: dict[str, Any] | None = None
|
|
66
|
+
self._admission: Any | None = None
|
|
67
|
+
self._input_checkpoint_seen = False
|
|
68
|
+
self._assemblers: dict[str, PartsAssembler] = {}
|
|
69
|
+
self._touched: set[tuple[str, str]] = set()
|
|
70
|
+
self._pending_subagents: dict[str, str] = {}
|
|
71
|
+
self.interrupt: dict[str, Any] | None = None
|
|
72
|
+
|
|
73
|
+
async def boot(self) -> None:
|
|
74
|
+
"""Hydrate the tree from the index rows and the main branch window from the checkpointer."""
|
|
75
|
+
store = langgraph_checkpoints(self._agent, thread_id=self._thread_id, checkpoint_ns="")
|
|
76
|
+
self._thread = await checkpoint_thread(
|
|
77
|
+
store=store, index=self._index, thread_id=self._thread_id, role=deepagents_role
|
|
78
|
+
)
|
|
79
|
+
for row in await self._index.read_messages(self._thread_id):
|
|
80
|
+
ns = row["checkpoint_ns"] or MAIN_NS
|
|
81
|
+
self.tree.add(ns, row["value"], head=False)
|
|
82
|
+
for ns, thread in self.tree.threads.items():
|
|
83
|
+
if ns != MAIN_NS and thread.messages:
|
|
84
|
+
thread.head = max(thread.messages.values(), key=lambda m: m["seq"])["id"]
|
|
85
|
+
self._thread_state(ns)
|
|
86
|
+
if self._thread.transcript:
|
|
87
|
+
self.tree.set_head(MAIN_NS, self._harness_id(self._thread.transcript[-1]["id"]))
|
|
88
|
+
else:
|
|
89
|
+
self.tree.thread(MAIN_NS)
|
|
90
|
+
self._thread_state(MAIN_NS)
|
|
91
|
+
|
|
92
|
+
def _harness_id(self, langchain_id: str) -> str:
|
|
93
|
+
for _ns, message_id, message in self.tree:
|
|
94
|
+
if message_id == langchain_id or langchain_id in _provider_ids(message):
|
|
95
|
+
return message_id
|
|
96
|
+
raise KeyError(f"no harness message folds {langchain_id!r}")
|
|
97
|
+
|
|
98
|
+
def _langchain_tail(self, ns: str, message_id: str) -> str:
|
|
99
|
+
ids = _provider_ids(self.tree.get(ns, message_id))
|
|
100
|
+
return ids[-1] if ids else message_id
|
|
101
|
+
|
|
102
|
+
def _thread_state(self, ns: str, **fields: Any) -> None:
|
|
103
|
+
thread = self.tree.thread(ns)
|
|
104
|
+
current = plain(self._threads.get(ns)) or {"status": "idle"}
|
|
105
|
+
self._threads[ns] = {**current, **fields, "headId": thread.head}
|
|
106
|
+
|
|
107
|
+
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
|
+
}
|
|
120
|
+
|
|
121
|
+
def config(self) -> dict[str, Any]:
|
|
122
|
+
return {"configurable": {"thread_id": self._thread_id}}
|
|
123
|
+
|
|
124
|
+
async def plan_turn(self, ctx: Any) -> tuple[Any, dict[str, Any]]:
|
|
125
|
+
"""The dispatch's langgraph (payload, config): forks at the anchor when the batch does not extend the head."""
|
|
126
|
+
self.abandon_turn()
|
|
127
|
+
config = self.config()
|
|
128
|
+
if ctx.resume:
|
|
129
|
+
return None, config
|
|
130
|
+
thread = self.tree.thread(MAIN_NS)
|
|
131
|
+
if ctx.trigger == "input-resume":
|
|
132
|
+
from langgraph.types import Command
|
|
133
|
+
|
|
134
|
+
decisions = [
|
|
135
|
+
{"type": "approve"} if response and response.get("decision") == "approve" else {"type": "reject"}
|
|
136
|
+
for _request, response, _meta in ctx.input_outcomes
|
|
137
|
+
]
|
|
138
|
+
self._admission = ctx.applied()
|
|
139
|
+
self._assemblers.pop(MAIN_NS, None)
|
|
140
|
+
return Command(resume={"decisions": decisions}), config
|
|
141
|
+
if ctx.has_rollback:
|
|
142
|
+
parent = ctx.rollback_to
|
|
143
|
+
elif ctx.messages:
|
|
144
|
+
parent = ctx.messages[0].get("parentId", thread.head)
|
|
145
|
+
else:
|
|
146
|
+
parent = thread.head
|
|
147
|
+
if parent is not None and parent not in thread.messages:
|
|
148
|
+
raise KeyError(f"anchor {parent!r} is not in the thread")
|
|
149
|
+
fork = parent != thread.head
|
|
150
|
+
if fork:
|
|
151
|
+
rollback = None if parent is None else self._langchain_tail(MAIN_NS, parent)
|
|
152
|
+
if rollback is not None and all(m["id"] != rollback for m in self._thread.transcript):
|
|
153
|
+
await self._thread.locate(rollback)
|
|
154
|
+
plan = await self._thread.plan("message-edit", rollback_to=rollback)
|
|
155
|
+
else:
|
|
156
|
+
plan = await self._thread.plan(ctx.trigger if ctx.trigger in ("message-send", "steer", "error-continue", "stop-continue") else "message-send")
|
|
157
|
+
from langchain_core.messages import HumanMessage
|
|
158
|
+
|
|
159
|
+
turn: list[Any] = [*plan.replay]
|
|
160
|
+
for message in ctx.messages:
|
|
161
|
+
turn.append(HumanMessage(id=message["id"], content=_user_text(message)))
|
|
162
|
+
self._append_user(message, parent)
|
|
163
|
+
parent = message["id"]
|
|
164
|
+
self.tree.set_head(MAIN_NS, parent)
|
|
165
|
+
self._thread_state(MAIN_NS)
|
|
166
|
+
self._admission = ctx.applied()
|
|
167
|
+
self._assemblers.pop(MAIN_NS, None)
|
|
168
|
+
if plan.fork:
|
|
169
|
+
if plan.checkpoint_id is not None:
|
|
170
|
+
config["configurable"]["checkpoint_id"] = plan.checkpoint_id
|
|
171
|
+
else:
|
|
172
|
+
from langchain_core.messages import RemoveMessage
|
|
173
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
174
|
+
|
|
175
|
+
turn = [RemoveMessage(id=REMOVE_ALL_MESSAGES), *turn]
|
|
176
|
+
self._changed()
|
|
177
|
+
return ({"messages": turn} if turn else None), config
|
|
178
|
+
|
|
179
|
+
def _append_user(self, message: dict[str, Any], parent: str | None) -> None:
|
|
180
|
+
if self.tree.get(MAIN_NS, message["id"]) is not None:
|
|
181
|
+
return
|
|
182
|
+
value = {
|
|
183
|
+
"id": message["id"],
|
|
184
|
+
"parentId": parent,
|
|
185
|
+
"role": "user",
|
|
186
|
+
"parts": [dict(p) for p in message["parts"]],
|
|
187
|
+
}
|
|
188
|
+
if message.get("metadata"):
|
|
189
|
+
value["metadata"] = dict(message["metadata"])
|
|
190
|
+
self.tree.add(MAIN_NS, value)
|
|
191
|
+
self._touched.add((MAIN_NS, message["id"]))
|
|
192
|
+
|
|
193
|
+
def append_users(self, messages: Any) -> list[Any]:
|
|
194
|
+
"""Append steered user messages after the head; returns the langgraph turn entries."""
|
|
195
|
+
from langchain_core.messages import HumanMessage
|
|
196
|
+
|
|
197
|
+
thread = self.tree.thread(MAIN_NS)
|
|
198
|
+
turn = []
|
|
199
|
+
for message in messages:
|
|
200
|
+
self._append_user(message, thread.head)
|
|
201
|
+
turn.append(HumanMessage(id=message["id"], content=_user_text(message)))
|
|
202
|
+
self._thread_state(MAIN_NS)
|
|
203
|
+
self._assemblers.pop(MAIN_NS, None)
|
|
204
|
+
self._changed()
|
|
205
|
+
return turn
|
|
206
|
+
|
|
207
|
+
def admit(self, admission: Any) -> None:
|
|
208
|
+
"""Hold a dispatch admission until the next root checkpoint confirms its input."""
|
|
209
|
+
self._admission = admission
|
|
210
|
+
|
|
211
|
+
def checkpoint_saved(self, config: Any, checkpoint: Any, metadata: Any) -> None:
|
|
212
|
+
"""Confirm this turn's input from a FrontierCheckpointer checkpoint callback."""
|
|
213
|
+
configurable = config["configurable"]
|
|
214
|
+
if (
|
|
215
|
+
configurable["thread_id"] == self._thread_id
|
|
216
|
+
and configurable.get("checkpoint_ns", "") == ""
|
|
217
|
+
and metadata["source"] in ("input", "loop")
|
|
218
|
+
):
|
|
219
|
+
self._confirm_admission()
|
|
220
|
+
|
|
221
|
+
def _confirm_admission(self) -> None:
|
|
222
|
+
if self._admission is not None:
|
|
223
|
+
self._admission.dur()
|
|
224
|
+
self._admission = None
|
|
225
|
+
|
|
226
|
+
def _assembler(self, ns: str, message_id: str) -> PartsAssembler:
|
|
227
|
+
assembler = self._assemblers.get(ns)
|
|
228
|
+
if assembler is None:
|
|
229
|
+
thread = self.tree.thread(ns)
|
|
230
|
+
value = {
|
|
231
|
+
"id": message_id,
|
|
232
|
+
"parentId": thread.head,
|
|
233
|
+
"role": "assistant",
|
|
234
|
+
"parts": [],
|
|
235
|
+
"metadata": {"provider": {"ids": []}},
|
|
236
|
+
}
|
|
237
|
+
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()
|
|
242
|
+
return assembler
|
|
243
|
+
|
|
244
|
+
def _fold_id(self, assembler: PartsAssembler, langchain_id: str | None) -> None:
|
|
245
|
+
if langchain_id is None:
|
|
246
|
+
return
|
|
247
|
+
ids = assembler.message["metadata"]["provider"]["ids"]
|
|
248
|
+
if langchain_id not in plain(ids):
|
|
249
|
+
ids.append(langchain_id)
|
|
250
|
+
|
|
251
|
+
def _spawn(self, ns: str, description: str) -> None:
|
|
252
|
+
main = self._assemblers.get(MAIN_NS)
|
|
253
|
+
if main is None:
|
|
254
|
+
return
|
|
255
|
+
for part in plain(main.parts):
|
|
256
|
+
if (
|
|
257
|
+
part["type"] == "tool"
|
|
258
|
+
and part["toolName"] == "task"
|
|
259
|
+
and part["state"] == "streaming"
|
|
260
|
+
and part["input"].get("description") == description
|
|
261
|
+
and not (part.get("metadata") or {}).get("ns")
|
|
262
|
+
):
|
|
263
|
+
main.amend(part["toolInvocationId"], metadata={"ns": ns})
|
|
264
|
+
title = str(part["input"].get("subagent_type") or "subagent")
|
|
265
|
+
self._thread_state(ns, title=f"{title}: {description}"[:80])
|
|
266
|
+
self._changed()
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
def append_event(self, namespace: tuple[str, ...], mode: str, event: Any) -> None:
|
|
270
|
+
"""Fold one ``astream`` event; the root checkpoint stream is captured for the turn's ingest."""
|
|
271
|
+
ns = _ns_of(namespace)
|
|
272
|
+
if mode == "checkpoints":
|
|
273
|
+
if ns == MAIN_NS:
|
|
274
|
+
self._captured = event
|
|
275
|
+
if event["metadata"]["source"] in ("input", "loop"):
|
|
276
|
+
self._input_checkpoint_seen = True
|
|
277
|
+
return
|
|
278
|
+
messages = event["values"].get("messages", [])
|
|
279
|
+
if ns not in self.tree.threads and messages:
|
|
280
|
+
first = messages[0]
|
|
281
|
+
self.tree.add(
|
|
282
|
+
ns,
|
|
283
|
+
{
|
|
284
|
+
"id": first.id,
|
|
285
|
+
"parentId": None,
|
|
286
|
+
"role": "user",
|
|
287
|
+
"parts": [{"type": "text", "text": text_of(first.content), "state": "done"}],
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
self._touched.add((ns, first.id))
|
|
291
|
+
self._thread_state(ns, status="streaming")
|
|
292
|
+
self._spawn(ns, text_of(first.content))
|
|
293
|
+
self._changed()
|
|
294
|
+
return
|
|
295
|
+
if mode == "messages":
|
|
296
|
+
message, meta = event
|
|
297
|
+
kind = getattr(message, "type", "")
|
|
298
|
+
if kind in ("ai", "AIMessageChunk"):
|
|
299
|
+
assembler = self._assembler(ns, message.id or f"ai-{len(self.tree.thread(ns).messages)}")
|
|
300
|
+
self._fold_id(assembler, message.id)
|
|
301
|
+
assembler.ai_chunk(message)
|
|
302
|
+
elif kind == "tool":
|
|
303
|
+
assembler = self._assemblers.get(ns)
|
|
304
|
+
if assembler is not None:
|
|
305
|
+
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:
|
|
308
|
+
assembler.amend(message.tool_call_id, metadata={"ns": sub_ns})
|
|
309
|
+
self._finish(sub_ns)
|
|
310
|
+
assembler.tool_result(message)
|
|
311
|
+
self._changed()
|
|
312
|
+
return
|
|
313
|
+
if mode == "updates" and isinstance(event, dict) and "__interrupt__" in event and ns == MAIN_NS:
|
|
314
|
+
assembler = self._assemblers.get(MAIN_NS)
|
|
315
|
+
waiting = assembler.streaming_tools() if assembler is not None else []
|
|
316
|
+
if waiting:
|
|
317
|
+
tool_id = waiting[-1]
|
|
318
|
+
assembler.amend(tool_id, state="pendingApproval")
|
|
319
|
+
self.interrupt = {"toolCallId": tool_id}
|
|
320
|
+
self._changed()
|
|
321
|
+
|
|
322
|
+
def _finish(self, ns: str) -> None:
|
|
323
|
+
assembler = self._assemblers.pop(ns, None)
|
|
324
|
+
if assembler is not None:
|
|
325
|
+
assembler.finish()
|
|
326
|
+
self._thread_state(ns, status="idle")
|
|
327
|
+
|
|
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."""
|
|
330
|
+
captured, self._captured = self._captured, None
|
|
331
|
+
if refetch:
|
|
332
|
+
await self._thread.ingest_latest()
|
|
333
|
+
elif captured is None:
|
|
334
|
+
raise RuntimeError("turn did not produce a root checkpoint")
|
|
335
|
+
else:
|
|
336
|
+
await self._thread.ingest(langgraph_snapshot(captured))
|
|
337
|
+
if self._input_checkpoint_seen:
|
|
338
|
+
self._confirm_admission()
|
|
339
|
+
for ns in list(self._assemblers):
|
|
340
|
+
if self.interrupt is None or ns != MAIN_NS:
|
|
341
|
+
self._finish(ns)
|
|
342
|
+
for ns in self.tree.threads:
|
|
343
|
+
if ns != MAIN_NS:
|
|
344
|
+
self._thread_state(ns, status="idle")
|
|
345
|
+
await self._persist()
|
|
346
|
+
self._changed()
|
|
347
|
+
|
|
348
|
+
async def _persist(self) -> None:
|
|
349
|
+
touched, self._touched = self._touched, set()
|
|
350
|
+
rows = [
|
|
351
|
+
{
|
|
352
|
+
"thread_id": self._thread_id,
|
|
353
|
+
"checkpoint_ns": _checkpoint_ns(ns),
|
|
354
|
+
"message_id": message_id,
|
|
355
|
+
"parent_id": self.tree.get(ns, message_id)["parentId"],
|
|
356
|
+
"seq": self.tree.get(ns, message_id)["seq"],
|
|
357
|
+
"value": plain(self.tree.get(ns, message_id)),
|
|
358
|
+
}
|
|
359
|
+
for ns, message_id in sorted(touched, key=lambda t: self.tree.get(*t)["seq"])
|
|
360
|
+
]
|
|
361
|
+
if rows:
|
|
362
|
+
await self._index.upsert_messages(rows)
|
|
363
|
+
|
|
364
|
+
def abandon_turn(self) -> None:
|
|
365
|
+
"""Discard a stream capture and any fork planned for the abandoned turn."""
|
|
366
|
+
self._captured = None
|
|
367
|
+
self._admission = None
|
|
368
|
+
self._input_checkpoint_seen = False
|
|
369
|
+
self.interrupt = None
|
|
370
|
+
for ns in list(self._assemblers):
|
|
371
|
+
self._finish(ns)
|
|
372
|
+
self._thread.abandon_plan()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _provider_ids(message: Any) -> list[str]:
|
|
376
|
+
metadata = plain(message.get("metadata")) or {}
|
|
377
|
+
return list((metadata.get("provider") or {}).get("ids") or [])
|