harness-sdk-python 0.32.2__py3-none-any.whl → 0.34.0__py3-none-any.whl
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/__init__.py +19 -1
- harness_sdk/checkpoints.py +83 -18
- harness_sdk/deepagents.py +145 -75
- harness_sdk/run_manager.py +201 -105
- harness_sdk/voice.py +83 -12
- harness_sdk_python-0.34.0.dist-info/METADATA +64 -0
- harness_sdk_python-0.34.0.dist-info/RECORD +10 -0
- harness_sdk_python-0.32.2.dist-info/METADATA +0 -66
- harness_sdk_python-0.32.2.dist-info/RECORD +0 -10
- {harness_sdk_python-0.32.2.dist-info → harness_sdk_python-0.34.0.dist-info}/WHEEL +0 -0
harness_sdk/__init__.py
CHANGED
|
@@ -8,16 +8,26 @@ from .checkpoints import (
|
|
|
8
8
|
Snapshot,
|
|
9
9
|
checkpoint_thread,
|
|
10
10
|
langgraph_checkpoints,
|
|
11
|
+
langgraph_checkpointer,
|
|
11
12
|
langgraph_snapshot,
|
|
13
|
+
langgraph_state_snapshot,
|
|
12
14
|
langgraph_switch_values,
|
|
13
15
|
)
|
|
14
|
-
from .deepagents import
|
|
16
|
+
from .deepagents import (
|
|
17
|
+
DeepagentsMessages,
|
|
18
|
+
deepagents_drop_ephemeral,
|
|
19
|
+
deepagents_ingest,
|
|
20
|
+
deepagents_restamp,
|
|
21
|
+
deepagents_role,
|
|
22
|
+
deepagents_upsert_ephemeral,
|
|
23
|
+
)
|
|
15
24
|
from .linear_thread import linear_thread
|
|
16
25
|
from .run_manager import RunManager, RunManagerDescriptor, run_manager
|
|
17
26
|
from .voice import (
|
|
18
27
|
VoiceManager,
|
|
19
28
|
VoiceManagerDescriptor,
|
|
20
29
|
VoiceProvider,
|
|
30
|
+
VoiceTurnCommitDeferred,
|
|
21
31
|
end_voice,
|
|
22
32
|
start_voice,
|
|
23
33
|
temp_frontend_tool_result,
|
|
@@ -39,10 +49,18 @@ __all__ = [
|
|
|
39
49
|
"VoiceManager",
|
|
40
50
|
"VoiceManagerDescriptor",
|
|
41
51
|
"VoiceProvider",
|
|
52
|
+
"VoiceTurnCommitDeferred",
|
|
42
53
|
"checkpoint_thread",
|
|
54
|
+
"deepagents_drop_ephemeral",
|
|
55
|
+
"deepagents_ingest",
|
|
56
|
+
"deepagents_restamp",
|
|
57
|
+
"deepagents_role",
|
|
58
|
+
"deepagents_upsert_ephemeral",
|
|
43
59
|
"end_voice",
|
|
44
60
|
"langgraph_checkpoints",
|
|
61
|
+
"langgraph_checkpointer",
|
|
45
62
|
"langgraph_snapshot",
|
|
63
|
+
"langgraph_state_snapshot",
|
|
46
64
|
"langgraph_switch_values",
|
|
47
65
|
"linear_thread",
|
|
48
66
|
"run_manager",
|
harness_sdk/checkpoints.py
CHANGED
|
@@ -27,6 +27,7 @@ _TIP_TRIGGERS = (
|
|
|
27
27
|
"stop-continue",
|
|
28
28
|
"steer",
|
|
29
29
|
)
|
|
30
|
+
_UNREAD: Any = object()
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
@dataclass(frozen=True)
|
|
@@ -326,37 +327,66 @@ def _values_snapshot(
|
|
|
326
327
|
lineage = values.get(lineage_key)
|
|
327
328
|
if lineage is not None and not isinstance(lineage, dict):
|
|
328
329
|
raise ValueError(f"lineage channel must be an object, got {lineage!r}")
|
|
330
|
+
# an unwritten channel is absent from the checkpoint
|
|
331
|
+
messages = values.get(messages_key, [])
|
|
332
|
+
if not isinstance(messages, list):
|
|
333
|
+
raise TypeError(f"{messages_key} channel must be a list, got {messages!r}")
|
|
329
334
|
return Snapshot(
|
|
330
335
|
checkpoint_id=checkpoint_id,
|
|
331
|
-
messages=[_message_dict(m) for m in
|
|
336
|
+
messages=[_message_dict(m) for m in messages],
|
|
332
337
|
lineage=lineage,
|
|
333
338
|
)
|
|
334
339
|
|
|
335
340
|
|
|
336
|
-
def
|
|
337
|
-
|
|
341
|
+
def langgraph_state_snapshot(
|
|
342
|
+
state: Any,
|
|
343
|
+
*,
|
|
344
|
+
messages_key: str = "messages",
|
|
345
|
+
lineage_key: str = "lineage",
|
|
346
|
+
) -> Snapshot | None:
|
|
347
|
+
"""A Snapshot from a compiled graph's ``aget_state`` result; None for a thread without a checkpoint."""
|
|
348
|
+
if state.metadata is None:
|
|
349
|
+
return None
|
|
350
|
+
return _values_snapshot(
|
|
351
|
+
state.config["configurable"]["checkpoint_id"],
|
|
352
|
+
state.values,
|
|
353
|
+
messages_key,
|
|
354
|
+
lineage_key,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _thread_config(
|
|
359
|
+
thread_id: str, checkpoint_ns: str, checkpoint_id: str | None
|
|
360
|
+
) -> dict[str, Any]:
|
|
361
|
+
configurable: dict[str, Any] = {
|
|
362
|
+
"thread_id": thread_id,
|
|
363
|
+
"checkpoint_ns": checkpoint_ns,
|
|
364
|
+
}
|
|
365
|
+
if checkpoint_id is not None:
|
|
366
|
+
configurable["checkpoint_id"] = checkpoint_id
|
|
367
|
+
return {"configurable": configurable}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def langgraph_checkpointer(
|
|
371
|
+
checkpointer: Any,
|
|
338
372
|
*,
|
|
339
373
|
thread_id: str,
|
|
340
374
|
checkpoint_ns: str = "",
|
|
341
375
|
messages_key: str = "messages",
|
|
342
376
|
lineage_key: str = "lineage",
|
|
343
377
|
) -> CheckpointStore:
|
|
344
|
-
"""A CheckpointStore over a
|
|
378
|
+
"""A CheckpointStore over a langgraph checkpoint saver's ``aget_tuple``."""
|
|
345
379
|
|
|
346
380
|
class _Store:
|
|
347
381
|
async def get(self, checkpoint_id: str | None) -> Snapshot | None:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if checkpoint_id is not None:
|
|
353
|
-
configurable["checkpoint_id"] = checkpoint_id
|
|
354
|
-
state = await graph.aget_state({"configurable": configurable})
|
|
355
|
-
if state.metadata is None:
|
|
382
|
+
saved = await checkpointer.aget_tuple(
|
|
383
|
+
_thread_config(thread_id, checkpoint_ns, checkpoint_id)
|
|
384
|
+
)
|
|
385
|
+
if saved is None:
|
|
356
386
|
return None
|
|
357
387
|
return _values_snapshot(
|
|
358
|
-
|
|
359
|
-
|
|
388
|
+
saved.checkpoint["id"],
|
|
389
|
+
saved.checkpoint["channel_values"],
|
|
360
390
|
messages_key,
|
|
361
391
|
lineage_key,
|
|
362
392
|
)
|
|
@@ -364,6 +394,29 @@ def langgraph_checkpoints(
|
|
|
364
394
|
return _Store()
|
|
365
395
|
|
|
366
396
|
|
|
397
|
+
def langgraph_checkpoints(
|
|
398
|
+
graph: Any,
|
|
399
|
+
*,
|
|
400
|
+
thread_id: str,
|
|
401
|
+
checkpoint_ns: str = "",
|
|
402
|
+
messages_key: str = "messages",
|
|
403
|
+
lineage_key: str = "lineage",
|
|
404
|
+
) -> CheckpointStore:
|
|
405
|
+
"""A CheckpointStore over a compiled langgraph graph's ``aget_state``."""
|
|
406
|
+
|
|
407
|
+
class _Store:
|
|
408
|
+
async def get(self, checkpoint_id: str | None) -> Snapshot | None:
|
|
409
|
+
return langgraph_state_snapshot(
|
|
410
|
+
await graph.aget_state(
|
|
411
|
+
_thread_config(thread_id, checkpoint_ns, checkpoint_id)
|
|
412
|
+
),
|
|
413
|
+
messages_key=messages_key,
|
|
414
|
+
lineage_key=lineage_key,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return _Store()
|
|
418
|
+
|
|
419
|
+
|
|
367
420
|
def langgraph_snapshot(
|
|
368
421
|
payload: dict[str, Any],
|
|
369
422
|
*,
|
|
@@ -415,6 +468,10 @@ class CheckpointThread:
|
|
|
415
468
|
_path: list[int] = field(default_factory=lambda: [0])
|
|
416
469
|
_pending_fork: dict[str, Any] | None = None
|
|
417
470
|
|
|
471
|
+
def abandon_plan(self) -> None:
|
|
472
|
+
"""Discard a planned fork whose run will not be ingested."""
|
|
473
|
+
self._pending_fork = None
|
|
474
|
+
|
|
418
475
|
def _projection(self):
|
|
419
476
|
return linear_thread(messages=lambda: self.transcript, role=self._role)
|
|
420
477
|
|
|
@@ -463,6 +520,8 @@ class CheckpointThread:
|
|
|
463
520
|
|
|
464
521
|
async def plan(self, trigger: str, *, rollback_to: str | None = None) -> Plan:
|
|
465
522
|
"""The resumption for ``trigger``; a fork trigger names its last surviving message via ``rollback_to``."""
|
|
523
|
+
# an abandoned plan's fork must not leak into the next turn's ingest
|
|
524
|
+
self.abandon_plan()
|
|
466
525
|
if trigger in _TIP_TRIGGERS:
|
|
467
526
|
if rollback_to is not None:
|
|
468
527
|
raise ValueError(f"rollback_to is invalid for trigger {trigger!r}")
|
|
@@ -657,15 +716,21 @@ async def checkpoint_thread(
|
|
|
657
716
|
thread_id: str,
|
|
658
717
|
checkpoint_ns: str = "",
|
|
659
718
|
role: Callable[[dict[str, Any]], str],
|
|
719
|
+
snapshot: Snapshot | None = _UNREAD,
|
|
660
720
|
) -> CheckpointThread:
|
|
661
|
-
"""Boot a CheckpointThread: the thread row and the latest snapshot load concurrently."""
|
|
721
|
+
"""Boot a CheckpointThread: the thread row and the latest snapshot load concurrently; ``snapshot`` hands in a latest snapshot already read (``None`` for a thread without one) and skips the store read."""
|
|
662
722
|
if not isinstance(thread_id, str) or not thread_id:
|
|
663
723
|
raise ValueError(f"thread_id must be a non-empty string, got {thread_id!r}")
|
|
664
724
|
if not callable(role):
|
|
665
725
|
raise TypeError("role must be callable")
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
726
|
+
if snapshot is _UNREAD:
|
|
727
|
+
branches, snapshot = await asyncio.gather(
|
|
728
|
+
index.read_thread(thread_id, checkpoint_ns), store.get(None)
|
|
729
|
+
)
|
|
730
|
+
else:
|
|
731
|
+
if snapshot is not None and not isinstance(snapshot, Snapshot):
|
|
732
|
+
raise TypeError(f"snapshot must be a Snapshot or None, got {snapshot!r}")
|
|
733
|
+
branches = await index.read_thread(thread_id, checkpoint_ns)
|
|
669
734
|
thread = CheckpointThread(
|
|
670
735
|
transcript=[],
|
|
671
736
|
branches=branches,
|
harness_sdk/deepagents.py
CHANGED
|
@@ -8,6 +8,7 @@ the target branch's tip as the checkpointer's latest checkpoint, so resumption
|
|
|
8
8
|
and reboot both land on the switched world.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import asyncio
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -15,16 +16,20 @@ from statepatch import plain
|
|
|
15
16
|
|
|
16
17
|
from .checkpoints import (
|
|
17
18
|
CheckpointIndex,
|
|
18
|
-
|
|
19
|
+
CheckpointThread,
|
|
19
20
|
Snapshot,
|
|
20
|
-
_values_snapshot,
|
|
21
21
|
checkpoint_thread,
|
|
22
|
+
langgraph_checkpointer,
|
|
22
23
|
langgraph_snapshot,
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
def
|
|
27
|
-
|
|
27
|
+
def deepagents_role(message: dict[str, Any]) -> str:
|
|
28
|
+
"""The harness role of a deepagents message: ``human`` is the user, ``ai``/``tool``/``system`` the assistant."""
|
|
29
|
+
try:
|
|
30
|
+
return _TYPE_TO_ROLE[message["type"]]
|
|
31
|
+
except KeyError as exc:
|
|
32
|
+
raise ValueError(f"unsupported message type: {message['type']!r}") from exc
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
def _user_text(message: dict[str, Any]) -> str:
|
|
@@ -33,28 +38,102 @@ def _user_text(message: dict[str, Any]) -> str:
|
|
|
33
38
|
)
|
|
34
39
|
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
_ROLE_TO_TYPE = {"user": "human", "assistant": "ai"}
|
|
42
|
+
_TYPE_TO_ROLE = {"human": "user", "ai": "assistant", "tool": "assistant", "system": "assistant"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _message_type(role: str) -> str:
|
|
46
|
+
try:
|
|
47
|
+
return _ROLE_TO_TYPE[role]
|
|
48
|
+
except KeyError as exc:
|
|
49
|
+
raise ValueError(f"unsupported message role: {role!r}") from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _next_metadata(saved: Any | None) -> dict[str, Any]:
|
|
53
|
+
if saved is None:
|
|
54
|
+
return {"source": "update", "step": 0, "parents": {}}
|
|
55
|
+
metadata = saved.metadata
|
|
56
|
+
if not isinstance(metadata, dict):
|
|
57
|
+
raise TypeError(f"checkpoint metadata must be an object, got {metadata!r}")
|
|
58
|
+
step = metadata["step"]
|
|
59
|
+
if isinstance(step, bool) or not isinstance(step, int):
|
|
60
|
+
raise TypeError(f"checkpoint metadata step must be an integer, got {step!r}")
|
|
61
|
+
return {**metadata, "source": "update", "step": step + 1}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _checkpoint_messages(checkpoint: dict[str, Any]) -> list[Any]:
|
|
65
|
+
# an unwritten channel is absent from the checkpoint
|
|
66
|
+
messages = checkpoint["channel_values"].get("messages", [])
|
|
67
|
+
if not isinstance(messages, list):
|
|
68
|
+
raise TypeError(f"messages channel must be a list, got {messages!r}")
|
|
69
|
+
return messages
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def deepagents_restamp(
|
|
73
|
+
state: Any,
|
|
74
|
+
thread: CheckpointThread,
|
|
75
|
+
ephemeral: dict[str, dict[str, Any]],
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Replace a Statewire messages projection with a thread and ephemeral entries."""
|
|
78
|
+
from statewire.langgraph import replace_messages
|
|
79
|
+
|
|
80
|
+
stamped = []
|
|
81
|
+
for message in thread.transcript:
|
|
82
|
+
branch, siblings = thread.sibling_info(message["id"])
|
|
83
|
+
stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
|
|
84
|
+
durable = {message["id"] for message in stamped}
|
|
85
|
+
stamped.extend(
|
|
86
|
+
{**entry, "ephemeral": True}
|
|
87
|
+
for id_, entry in ephemeral.items()
|
|
88
|
+
if id_ not in durable
|
|
89
|
+
)
|
|
90
|
+
replace_messages(state, stamped)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def deepagents_upsert_ephemeral(
|
|
94
|
+
state: Any,
|
|
95
|
+
thread: CheckpointThread,
|
|
96
|
+
ephemeral: dict[str, dict[str, Any]],
|
|
97
|
+
message: dict[str, Any],
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Add or replace one ephemeral deepagents message and restamp the projection."""
|
|
100
|
+
message_type = _message_type(message["role"])
|
|
101
|
+
if message["id"] not in ephemeral and any(
|
|
102
|
+
m["id"] == message["id"] for m in thread.transcript
|
|
103
|
+
):
|
|
104
|
+
raise ValueError(f"message {message['id']!r} is already durable")
|
|
105
|
+
ephemeral[message["id"]] = {
|
|
106
|
+
"id": message["id"],
|
|
107
|
+
"type": message_type,
|
|
108
|
+
"content": message["text"],
|
|
109
|
+
}
|
|
110
|
+
deepagents_restamp(state, thread, ephemeral)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def deepagents_drop_ephemeral(
|
|
114
|
+
state: Any,
|
|
115
|
+
thread: CheckpointThread,
|
|
116
|
+
ephemeral: dict[str, dict[str, Any]],
|
|
117
|
+
message_id: str,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Remove one ephemeral deepagents message and restamp the projection."""
|
|
120
|
+
del ephemeral[message_id]
|
|
121
|
+
deepagents_restamp(state, thread, ephemeral)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def deepagents_ingest(
|
|
125
|
+
thread: CheckpointThread,
|
|
126
|
+
captured: dict[str, Any] | None,
|
|
127
|
+
*,
|
|
128
|
+
refetch: bool = False,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Ingest a captured root checkpoint or explicitly refetch the checkpointer tip."""
|
|
131
|
+
if refetch:
|
|
132
|
+
await thread.ingest_latest()
|
|
133
|
+
elif captured is None:
|
|
134
|
+
raise RuntimeError("turn did not produce a root checkpoint")
|
|
135
|
+
else:
|
|
136
|
+
await thread.ingest(langgraph_snapshot(captured))
|
|
58
137
|
|
|
59
138
|
|
|
60
139
|
class DeepagentsMessages:
|
|
@@ -68,6 +147,7 @@ class DeepagentsMessages:
|
|
|
68
147
|
self._checkpointer = checkpointer
|
|
69
148
|
self._captured: dict[str, Any] | None = None
|
|
70
149
|
self._ephemeral: dict[str, dict[str, Any]] = {}
|
|
150
|
+
self._write_lock = asyncio.Lock()
|
|
71
151
|
|
|
72
152
|
async def boot(
|
|
73
153
|
self, state: Any, *, index: CheckpointIndex, thread_id: str
|
|
@@ -75,16 +155,22 @@ class DeepagentsMessages:
|
|
|
75
155
|
"""Adopt ``state["messages"]`` and load the thread's history into it with branch stamps."""
|
|
76
156
|
self._state = state
|
|
77
157
|
self._thread_id = thread_id
|
|
78
|
-
self._store =
|
|
158
|
+
self._store = langgraph_checkpointer(
|
|
159
|
+
self._checkpointer, thread_id=thread_id, checkpoint_ns=""
|
|
160
|
+
)
|
|
79
161
|
self._thread = await checkpoint_thread(
|
|
80
|
-
store=self._store, index=index, thread_id=thread_id, role=
|
|
162
|
+
store=self._store, index=index, thread_id=thread_id, role=deepagents_role
|
|
81
163
|
)
|
|
82
164
|
if self._thread.transcript:
|
|
83
165
|
# the graph carries no lineage channel: re-derive the world path from the tail row
|
|
84
166
|
row = await index.get_message(
|
|
85
167
|
thread_id, "", self._thread.transcript[-1]["id"]
|
|
86
168
|
)
|
|
87
|
-
if row is
|
|
169
|
+
if row is None:
|
|
170
|
+
raise KeyError(
|
|
171
|
+
f"message {self._thread.transcript[-1]['id']!r} is not indexed"
|
|
172
|
+
)
|
|
173
|
+
if row["branch_id"] != 0:
|
|
88
174
|
await self._thread.switch_state(row["branch_id"])
|
|
89
175
|
self._restamp()
|
|
90
176
|
|
|
@@ -104,6 +190,8 @@ class DeepagentsMessages:
|
|
|
104
190
|
self, ctx: Any
|
|
105
191
|
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
|
106
192
|
"""The dispatch's langgraph (payload, config): plans the resumption, restores a rewind's window, appends the batch to the wire state, and confirms it applied."""
|
|
193
|
+
self._captured = None
|
|
194
|
+
self._thread.abandon_plan()
|
|
107
195
|
config = self.config()
|
|
108
196
|
if ctx.resume:
|
|
109
197
|
return None, config
|
|
@@ -142,29 +230,32 @@ class DeepagentsMessages:
|
|
|
142
230
|
async def ingest_turn(self, *, refetch: bool = False) -> None:
|
|
143
231
|
"""Ingest the turn's captured checkpoint and restamp; ``refetch`` discards the capture and reads the checkpointer's latest instead."""
|
|
144
232
|
captured, self._captured = self._captured, None
|
|
145
|
-
|
|
146
|
-
await self._thread.ingest_latest()
|
|
147
|
-
else:
|
|
148
|
-
await self._thread.ingest(langgraph_snapshot(captured))
|
|
233
|
+
await deepagents_ingest(self._thread, captured, refetch=refetch)
|
|
149
234
|
self._restamp()
|
|
150
235
|
|
|
236
|
+
def abandon_turn(self) -> None:
|
|
237
|
+
"""Discard a stream capture and any fork planned for the abandoned turn."""
|
|
238
|
+
self._captured = None
|
|
239
|
+
self._thread.abandon_plan()
|
|
240
|
+
|
|
151
241
|
def append_users(self, messages: Any) -> list[dict[str, Any]]:
|
|
152
242
|
"""Append harness user messages to the wire state as human entries, deduplicated by id; returns the langgraph turn entries."""
|
|
153
243
|
return [self._append_user(message) for message in messages]
|
|
154
244
|
|
|
155
245
|
async def append_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
156
246
|
"""Append ``{id, role, text}`` entries as a new checkpoint on the current tip and restamp; the voice commit path."""
|
|
247
|
+
async with self._write_lock:
|
|
248
|
+
await self._append_messages(messages)
|
|
249
|
+
|
|
250
|
+
async def _append_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
157
251
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
158
252
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
159
253
|
from langgraph.checkpoint.base.id import uuid6
|
|
160
254
|
|
|
161
255
|
if not messages:
|
|
162
256
|
raise ValueError("messages must be non-empty")
|
|
163
|
-
for message in messages:
|
|
164
|
-
if message["role"] not in ("user", "assistant"):
|
|
165
|
-
raise ValueError(f"unsupported message role: {message['role']!r}")
|
|
166
257
|
entries = [
|
|
167
|
-
(HumanMessage if m["role"] == "
|
|
258
|
+
(HumanMessage if _message_type(m["role"]) == "human" else AIMessage)(
|
|
168
259
|
id=m["id"], content=m["text"]
|
|
169
260
|
)
|
|
170
261
|
for m in messages
|
|
@@ -173,10 +264,10 @@ class DeepagentsMessages:
|
|
|
173
264
|
saved = await self._checkpointer.aget_tuple(parent)
|
|
174
265
|
if saved is None:
|
|
175
266
|
checkpoint = empty_checkpoint()
|
|
176
|
-
|
|
267
|
+
previous_messages = []
|
|
177
268
|
else:
|
|
178
269
|
checkpoint = {**saved.checkpoint}
|
|
179
|
-
|
|
270
|
+
previous_messages = _checkpoint_messages(checkpoint)
|
|
180
271
|
parent["configurable"]["checkpoint_id"] = saved.checkpoint["id"]
|
|
181
272
|
version = self._checkpointer.get_next_version(
|
|
182
273
|
checkpoint["channel_versions"].get("messages"), None
|
|
@@ -188,7 +279,7 @@ class DeepagentsMessages:
|
|
|
188
279
|
"channel_values": {
|
|
189
280
|
**checkpoint["channel_values"],
|
|
190
281
|
"messages": [
|
|
191
|
-
*
|
|
282
|
+
*previous_messages,
|
|
192
283
|
*entries,
|
|
193
284
|
],
|
|
194
285
|
},
|
|
@@ -197,17 +288,18 @@ class DeepagentsMessages:
|
|
|
197
288
|
"messages": version,
|
|
198
289
|
},
|
|
199
290
|
}
|
|
200
|
-
metadata =
|
|
291
|
+
metadata = _next_metadata(saved)
|
|
201
292
|
await self._checkpointer.aput(parent, checkpoint, metadata, {"messages": version})
|
|
202
293
|
await self._thread.ingest_latest()
|
|
203
294
|
self._restamp()
|
|
204
295
|
|
|
205
296
|
async def switch_branch(self, branch_id: int) -> None:
|
|
206
297
|
"""Switch to ``branch_id``: its tip becomes the checkpointer's latest checkpoint and the wire state rebaselines."""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
298
|
+
async with self._write_lock:
|
|
299
|
+
transcript, lineage = await self._thread.switch_state(branch_id)
|
|
300
|
+
checkpoint_id = await self._clone_tip(self._thread.checkpoint_id)
|
|
301
|
+
await self._thread.ingest(Snapshot(checkpoint_id, transcript, lineage))
|
|
302
|
+
self._restamp()
|
|
211
303
|
|
|
212
304
|
async def _clone_tip(self, checkpoint_id: str) -> str:
|
|
213
305
|
from langgraph.checkpoint.base.id import uuid6
|
|
@@ -227,11 +319,7 @@ class DeepagentsMessages:
|
|
|
227
319
|
"id": str(uuid6(clock_seq=-2)),
|
|
228
320
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
229
321
|
}
|
|
230
|
-
metadata =
|
|
231
|
-
"source": "update",
|
|
232
|
-
"step": (saved.metadata or {}).get("step", -1) + 1,
|
|
233
|
-
"parents": {},
|
|
234
|
-
}
|
|
322
|
+
metadata = _next_metadata(saved)
|
|
235
323
|
written = await self._checkpointer.aput(parent, clone, metadata, {})
|
|
236
324
|
return written["configurable"]["checkpoint_id"]
|
|
237
325
|
|
|
@@ -245,23 +333,15 @@ class DeepagentsMessages:
|
|
|
245
333
|
|
|
246
334
|
def upsert_ephemeral_message(self, message: dict[str, Any]) -> None:
|
|
247
335
|
"""Render an in-flight ``{id, role, text}`` entry in ``state["messages"]`` tagged ``ephemeral: true``; an ``append_messages`` of the same id replaces it durably."""
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
m["id"] == message["id"] for m in self._thread.transcript
|
|
252
|
-
):
|
|
253
|
-
raise ValueError(f"message {message['id']!r} is already durable")
|
|
254
|
-
self._ephemeral[message["id"]] = {
|
|
255
|
-
"id": message["id"],
|
|
256
|
-
"type": "human" if message["role"] == "user" else "ai",
|
|
257
|
-
"content": message["text"],
|
|
258
|
-
}
|
|
259
|
-
self._restamp()
|
|
336
|
+
deepagents_upsert_ephemeral(
|
|
337
|
+
self._state, self._thread, self._ephemeral, message
|
|
338
|
+
)
|
|
260
339
|
|
|
261
340
|
def drop_ephemeral_message(self, message_id: str) -> None:
|
|
262
341
|
"""Remove an in-flight entry from the ``state["messages"]`` projection."""
|
|
263
|
-
|
|
264
|
-
|
|
342
|
+
deepagents_drop_ephemeral(
|
|
343
|
+
self._state, self._thread, self._ephemeral, message_id
|
|
344
|
+
)
|
|
265
345
|
|
|
266
346
|
def _append_user(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
267
347
|
entry = {"id": message["id"], "type": "human", "content": _user_text(message)}
|
|
@@ -275,14 +355,4 @@ class DeepagentsMessages:
|
|
|
275
355
|
replace_messages(self._state, messages)
|
|
276
356
|
|
|
277
357
|
def _restamp(self) -> None:
|
|
278
|
-
|
|
279
|
-
for message in self._thread.transcript:
|
|
280
|
-
branch, siblings = self._thread.sibling_info(message["id"])
|
|
281
|
-
stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
|
|
282
|
-
durable = {message["id"] for message in stamped}
|
|
283
|
-
stamped.extend(
|
|
284
|
-
{**entry, "ephemeral": True}
|
|
285
|
-
for id_, entry in self._ephemeral.items()
|
|
286
|
-
if id_ not in durable
|
|
287
|
-
)
|
|
288
|
-
self._replace(stamped)
|
|
358
|
+
deepagents_restamp(self._state, self._thread, self._ephemeral)
|