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 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 DeepagentsMessages
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",
@@ -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 values.get(messages_key, [])],
336
+ messages=[_message_dict(m) for m in messages],
332
337
  lineage=lineage,
333
338
  )
334
339
 
335
340
 
336
- def langgraph_checkpoints(
337
- graph: Any,
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 compiled langgraph graph's ``aget_state``."""
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
- configurable: dict[str, Any] = {
349
- "thread_id": thread_id,
350
- "checkpoint_ns": checkpoint_ns,
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
- state.config["configurable"]["checkpoint_id"],
359
- state.values,
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
- branches, snapshot = await asyncio.gather(
667
- index.read_thread(thread_id, checkpoint_ns), store.get(None)
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
- CheckpointStore,
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 _role(message: dict[str, Any]) -> str:
27
- return "user" if message["type"] == "human" else "assistant"
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
- def _checkpointer_store(
37
- checkpointer: Any, thread_id: str, checkpoint_ns: str
38
- ) -> CheckpointStore:
39
- class _Store:
40
- async def get(self, checkpoint_id: str | None) -> Snapshot | None:
41
- configurable: dict[str, Any] = {
42
- "thread_id": thread_id,
43
- "checkpoint_ns": checkpoint_ns,
44
- }
45
- if checkpoint_id is not None:
46
- configurable["checkpoint_id"] = checkpoint_id
47
- saved = await checkpointer.aget_tuple({"configurable": configurable})
48
- if saved is None:
49
- return None
50
- return _values_snapshot(
51
- saved.checkpoint["id"],
52
- saved.checkpoint["channel_values"],
53
- "messages",
54
- "lineage",
55
- )
56
-
57
- return _Store()
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 = _checkpointer_store(self._checkpointer, thread_id, "")
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=_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 not None and row["branch_id"] != 0:
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
- if refetch or captured is None:
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"] == "user" else AIMessage)(
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
- step = -1
267
+ previous_messages = []
177
268
  else:
178
269
  checkpoint = {**saved.checkpoint}
179
- step = (saved.metadata or {}).get("step", -1)
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
- *checkpoint["channel_values"].get("messages", []),
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 = {"source": "update", "step": step + 1, "parents": {}}
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
- transcript, lineage = await self._thread.switch_state(branch_id)
208
- checkpoint_id = await self._clone_tip(self._thread.checkpoint_id)
209
- await self._thread.ingest(Snapshot(checkpoint_id, transcript, lineage))
210
- self._restamp()
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
- if message["role"] not in ("user", "assistant"):
249
- raise ValueError(f"unsupported message role: {message['role']!r}")
250
- if message["id"] not in self._ephemeral and any(
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
- del self._ephemeral[message_id]
264
- self._restamp()
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
- stamped = []
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)