harness-sdk-python 0.36.5__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.
Files changed (63) hide show
  1. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/__init__.py +18 -13
  4. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/checkpoints.py +63 -11
  5. harness_sdk_python-0.37.0/src/harness_sdk/deepagents.py +377 -0
  6. harness_sdk_python-0.37.0/src/harness_sdk/deepagents_host.py +133 -0
  7. harness_sdk_python-0.37.0/src/harness_sdk/interest.py +120 -0
  8. harness_sdk_python-0.37.0/src/harness_sdk/messages.py +116 -0
  9. harness_sdk_python-0.37.0/src/harness_sdk/parts.py +148 -0
  10. harness_sdk_python-0.37.0/src/harness_sdk/protocol.py +12 -0
  11. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/run_manager.py +101 -17
  12. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/voice.py +11 -5
  13. harness_sdk_python-0.37.0/src/harness_sdk/wire.py +114 -0
  14. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/run_helpers.py +26 -29
  15. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_admit_steer.py +8 -3
  16. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_batches.py +5 -1
  17. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_checkpoints.py +4 -2
  18. harness_sdk_python-0.37.0/tests/test_deepagents_thread.py +173 -0
  19. harness_sdk_python-0.37.0/tests/test_interest.py +102 -0
  20. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_outcomes.py +10 -4
  21. harness_sdk_python-0.37.0/tests/test_parts.py +49 -0
  22. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_run_crash_sweep.py +10 -1
  23. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_settle.py +3 -5
  24. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_stop_continue.py +2 -2
  25. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_stopping_window.py +1 -1
  26. harness_sdk_python-0.37.0/tests/test_undo.py +301 -0
  27. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_voice.py +110 -67
  28. harness_sdk_python-0.37.0/tests/test_wire.py +108 -0
  29. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/voice_helpers.py +24 -5
  30. harness_sdk_python-0.36.5/src/harness_sdk/deepagents.py +0 -386
  31. harness_sdk_python-0.36.5/src/harness_sdk/protocol.py +0 -5
  32. harness_sdk_python-0.36.5/tests/test_deepagents_messages.py +0 -510
  33. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/.gitignore +0 -0
  34. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/README.md +0 -0
  35. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/examples/__init__.py +0 -0
  36. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/examples/runs_app.py +0 -0
  37. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/fenced_postgres.py +0 -0
  38. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/langgraph.py +0 -0
  39. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/src/harness_sdk/linear_thread.py +0 -0
  40. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_applied_visibility.py +0 -0
  41. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_branch_anchor.py +0 -0
  42. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_dispatching.py +0 -0
  43. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_durability.py +0 -0
  44. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_edit_dispatched.py +0 -0
  45. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_edit_reload.py +0 -0
  46. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_enqueue.py +0 -0
  47. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_fenced_postgres.py +0 -0
  48. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_input_required.py +0 -0
  49. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_langgraph.py +0 -0
  50. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_linear_thread.py +0 -0
  51. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_meta.py +0 -0
  52. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_mint_concurrency.py +0 -0
  53. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_placement.py +0 -0
  54. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_prepare_hooks.py +0 -0
  55. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_restore.py +0 -0
  56. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_rewind_during_run.py +0 -0
  57. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_run_admission.py +0 -0
  58. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_run_eviction.py +0 -0
  59. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_run_ownership.py +0 -0
  60. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_run_sync.py +0 -0
  61. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_steer.py +0 -0
  62. {harness_sdk_python-0.36.5 → harness_sdk_python-0.37.0}/tests/test_voice_delegation.py +0 -0
  63. {harness_sdk_python-0.36.5 → 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.36.5
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.18,>=0.17.4
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.36.5"
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.17.4,<0.18", "pinned>=0.15.2,<0.16"]
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
- DeepagentsMessages,
19
- deepagents_drop_ephemeral,
20
- deepagents_ingest,
21
- deepagents_restamp,
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
- "DeepagentsMessages",
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
- "deepagents_drop_ephemeral",
57
- "deepagents_ingest",
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["anchor_checkpoint_id"],
151
- "branch_id": row["branch_id"],
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 NOT NULL,"
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 NOTHING",
291
- rows,
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 FROM checkpoint_messages"
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 {"anchor_checkpoint_id": row[0], "branch_id": row[1]}
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 [])