harness-sdk-python 0.9.0__tar.gz → 0.10.1__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 (30) hide show
  1. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/.gitignore +2 -0
  2. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/PKG-INFO +2 -2
  3. harness_sdk_python-0.10.1/examples/__init__.py +0 -0
  4. harness_sdk_python-0.10.1/examples/runs_app.py +203 -0
  5. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/pyproject.toml +2 -2
  6. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/run_manager.py +68 -54
  7. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/run_helpers.py +60 -3
  8. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_ack_visibility.py +2 -2
  9. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_batches.py +5 -2
  10. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_branch_anchor.py +46 -6
  11. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_dispatching.py +2 -2
  12. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_edit_dispatched.py +6 -6
  13. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_edit_reload.py +107 -19
  14. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_enqueue.py +13 -12
  15. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_facade.py +18 -11
  16. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_input_required.py +15 -4
  17. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_meta.py +5 -5
  18. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_placement.py +4 -4
  19. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_prepare_hooks.py +10 -18
  20. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_rewind_during_run.py +18 -9
  21. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_settle.py +27 -30
  22. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_steer.py +6 -6
  23. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_stop_continue.py +36 -24
  24. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/README.md +0 -0
  25. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/__init__.py +0 -0
  26. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/fenced_postgres.py +0 -0
  27. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/linear_thread.py +0 -0
  28. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_fenced_postgres.py +0 -0
  29. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_linear_thread.py +0 -0
  30. {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_outcomes.py +0 -0
@@ -22,3 +22,5 @@ __pycache__
22
22
  /doc_*.md
23
23
  apps/docs/.docs
24
24
  /.agentdoc/
25
+ .vercel
26
+ .env*
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.9.0
3
+ Version: 0.10.1
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.11
8
- Requires-Dist: statewire<0.5,>=0.4.0
8
+ Requires-Dist: statewire<0.6,>=0.5.0
9
9
  Provides-Extra: deepagents
10
10
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
11
11
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
File without changes
@@ -0,0 +1,203 @@
1
+ """Minimal RunManager backend: a no-tools deepagents agent behind statewire.
2
+
3
+ Run from python/harness-sdk-python:
4
+
5
+ uv run --extra deepagents uvicorn examples.runs_app:app --port 8000
6
+
7
+ State is ``{"messages": [...]}`` in langchain format; rewind truncates the
8
+ list. Threads live at /threads/{id}: ``GET .../stream`` (SSE), ``.../ws``,
9
+ ``POST .../commands``, and ``POST .../assistant-transport/api/chat``.
10
+
11
+ Set PINBOARD_URL, PINBOARD_TOKEN, and ADVERTISE_URL to register with a
12
+ pinboard control plane instead of local placement.
13
+ """
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import os
18
+ from typing import Any
19
+
20
+ from fastapi import FastAPI
21
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
22
+ from pinned import PinnedHost
23
+ from statewire import AssistantTransport, command, plain
24
+ from statewire.langgraph import append_langgraph_event
25
+
26
+ from harness_sdk import RunManager, linear_thread
27
+
28
+
29
+ def select_model() -> str:
30
+ if model := os.environ.get("DEEPAGENTS_MODEL"):
31
+ return model
32
+ if os.environ.get("OPENAI_API_KEY"):
33
+ return "openai:gpt-4o"
34
+ return "anthropic:claude-opus-4-8"
35
+
36
+
37
+ def build_agent() -> Any:
38
+ from deepagents import create_deep_agent
39
+ from langgraph.checkpoint.memory import MemorySaver
40
+
41
+ return create_deep_agent(
42
+ model=select_model(),
43
+ tools=[],
44
+ system_prompt="You are a concise assistant.",
45
+ checkpointer=MemorySaver(),
46
+ )
47
+
48
+
49
+ def to_langchain(message: dict[str, Any]) -> Any:
50
+ match message["type"]:
51
+ case "human":
52
+ return HumanMessage(id=message["id"], content=message["content"])
53
+ case "ai":
54
+ return AIMessage(
55
+ id=message["id"],
56
+ content=message["content"],
57
+ tool_calls=message.get("tool_calls") or [],
58
+ )
59
+ case "tool":
60
+ return ToolMessage(
61
+ id=message["id"],
62
+ content=message["content"],
63
+ tool_call_id=message["tool_call_id"],
64
+ )
65
+ case other:
66
+ raise ValueError(f"unsupported message type: {other}")
67
+
68
+
69
+ def user_text(message: dict[str, Any]) -> str:
70
+ return "\n\n".join(
71
+ part["text"] for part in message["parts"] if part["type"] == "text"
72
+ )
73
+
74
+
75
+ class RunsHost(AssistantTransport):
76
+ assistant_transport_protocol = "assistant-transport"
77
+
78
+ async def lifespan(self):
79
+ self.agent = build_agent()
80
+ self.state = {"messages": []}
81
+ self.thread = linear_thread(
82
+ messages=lambda: plain(self.state["messages"]),
83
+ role=lambda m: "user" if m["type"] == "human" else "assistant",
84
+ )
85
+ self.turn = 0
86
+ self.runs = RunManager(
87
+ state=self.state,
88
+ run=self.run,
89
+ thread=self.thread,
90
+ create_task=self.create_task,
91
+ schedule=self.schedule,
92
+ capabilities=("rewind",),
93
+ )
94
+ yield
95
+
96
+ async def get_message_child_id(self, parent_id: str) -> str | None:
97
+ return await self.thread.get_message_child_id(parent_id)
98
+
99
+ async def run(self, ctx: RunManager.RunContext) -> Any:
100
+ if ctx.has_rollback:
101
+ self.truncate(ctx.rollback_to)
102
+ pending = list(ctx.messages)
103
+ while True:
104
+ for message in pending:
105
+ self.state["messages"].append(
106
+ {
107
+ "id": message["id"],
108
+ "type": "human",
109
+ "content": user_text(message),
110
+ }
111
+ )
112
+ ctx.ack()
113
+ outcome = await self.stream_turn(ctx)
114
+ if outcome is not None:
115
+ return outcome
116
+ pending = list(ctx.steering.take())
117
+ if not pending:
118
+ return RunManager.Complete()
119
+
120
+ def truncate(self, message_id: str | None) -> None:
121
+ messages = self.state["messages"]
122
+ keep = 0
123
+ if message_id is not None:
124
+ items = plain(messages)
125
+ keep = 1 + next(
126
+ index for index, m in enumerate(items) if m["id"] == message_id
127
+ )
128
+ while len(plain(messages)) > keep:
129
+ messages.pop()
130
+
131
+ async def stream_turn(self, ctx: RunManager.RunContext) -> Any:
132
+ history = [to_langchain(m) for m in plain(self.state["messages"])]
133
+ self.turn += 1
134
+ stream = self.agent.astream(
135
+ {"messages": history},
136
+ config={"configurable": {"thread_id": f"{self.id}:{self.turn}"}},
137
+ stream_mode=["messages", "updates"],
138
+ subgraphs=True,
139
+ )
140
+ iterator = stream.__aiter__()
141
+ stop = asyncio.ensure_future(ctx.stop_requested.wait())
142
+ try:
143
+ while True:
144
+ step = asyncio.ensure_future(anext(iterator))
145
+ await asyncio.wait({step, stop}, return_when=asyncio.FIRST_COMPLETED)
146
+ if stop.done() and not step.done():
147
+ step.cancel()
148
+ with contextlib.suppress(asyncio.CancelledError):
149
+ await step
150
+ return RunManager.Stop(dispatch_queue=True)
151
+ try:
152
+ namespace, mode, payload = await step
153
+ except StopAsyncIteration:
154
+ return None
155
+ append_langgraph_event(self.state, namespace, mode, payload)
156
+ finally:
157
+ stop.cancel()
158
+ with contextlib.suppress(Exception):
159
+ await stream.aclose()
160
+
161
+ @command("run/enqueue")
162
+ async def run_enqueue(self, params, *, ctx):
163
+ return await self.runs.enqueue(params, ack=ctx.ack)
164
+
165
+ @command("run/steer")
166
+ async def run_steer(self, params, *, ctx):
167
+ return await self.runs.steer(params, ack=ctx.ack)
168
+
169
+ @command("run/dequeue")
170
+ async def run_dequeue(self, params):
171
+ return await self.runs.dequeue(params)
172
+
173
+ @command("run/edit")
174
+ async def run_edit(self, params, *, ctx):
175
+ return await self.runs.edit(params, ack=ctx.ack)
176
+
177
+ @command("run/reload")
178
+ async def run_reload(self, params, *, ctx):
179
+ return await self.runs.reload(params, ack=ctx.ack)
180
+
181
+ @command("run/stop")
182
+ async def run_stop(self, params=None, *, ctx):
183
+ return await self.runs.stop(params, ack=ctx.ack)
184
+
185
+ @command("run/continue")
186
+ async def run_continue(self, params=None, *, ctx):
187
+ return await self.runs.continue_run(ack=ctx.ack)
188
+
189
+ @command("run/input")
190
+ async def run_input(self, params):
191
+ return await self.runs.input(params)
192
+
193
+
194
+ _pinboard_url = os.environ.get("PINBOARD_URL")
195
+ router = PinnedHost(
196
+ RunsHost,
197
+ pinboard_url=_pinboard_url,
198
+ token=os.environ.get("PINBOARD_TOKEN"),
199
+ advertise_url=os.environ.get("ADVERTISE_URL"),
200
+ namespace="/threads" if _pinboard_url else None,
201
+ )
202
+ app = FastAPI(lifespan=router.lifespan)
203
+ app.include_router(router, prefix="/threads")
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.9.0"
3
+ version = "0.10.1"
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.11,<4.0"
8
- dependencies = ["statewire>=0.4.0,<0.5"]
8
+ dependencies = ["statewire>=0.5.0,<0.6"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -8,11 +8,10 @@ reduces the whole intake in order, then takes exactly one action based on
8
8
  status and net effect (interrupt, dispatch, or continue). A dispatchable send
9
9
  appends straight into ``dispatching`` — it never transits a queue lane — and
10
10
  everything staged there by the end of the drain dispatches as one run. Every
11
- send acks and settles accepted with its state-visible insert (lane or
12
- ``dispatching``); edit, reload, and continue initiators ack at their dispatch
13
- and settle when their run ends accepted once the run has acked its
14
- messages, rejected otherwise; ``run/stop`` awaits a future the drain resolves
15
- once the in-flight run has ended.
11
+ initiator acks and settles accepted with its state-visible application
12
+ sends with their insert (lane or ``dispatching``), edit/reload/continue at
13
+ their dispatch; ``run/stop`` awaits a future the drain resolves once the
14
+ in-flight run has ended.
16
15
 
17
16
  ``entry["dispatching"]`` is the dispatch record (or None): the payload the
18
17
  run callback receives. ``ctx.ack()`` clears it; ``steering.take()`` may
@@ -23,7 +22,6 @@ the front of the queue, rewind replacements dropped.
23
22
  """
24
23
 
25
24
  import asyncio
26
- import uuid
27
25
  from dataclasses import dataclass, field
28
26
  from typing import Any, Awaitable, Callable, Iterable, Protocol
29
27
 
@@ -62,6 +60,13 @@ def _reject(reason: str, message: str) -> StatewireReject:
62
60
  return StatewireReject(message, payload={"reason": reason})
63
61
 
64
62
 
63
+ def _run_id_of(params: Any) -> str:
64
+ run_id = params.get("runId") if isinstance(params, dict) else None
65
+ if not isinstance(run_id, str) or run_id == "":
66
+ raise _reject("invalid-message", "runId must be a non-empty string")
67
+ return run_id
68
+
69
+
65
70
  def _future() -> "asyncio.Future[Any]":
66
71
  return asyncio.get_running_loop().create_future()
67
72
 
@@ -75,6 +80,7 @@ class _Send:
75
80
  source_meta: dict[str, Any] | None
76
81
  meta: Any
77
82
  ack: Callable[[], None]
83
+ run_id: str = ""
78
84
  anchor: Any = _ABSENT
79
85
  anchor_meta: dict[str, Any] | None = None
80
86
  thread_empty: bool = False
@@ -94,6 +100,7 @@ class _Edit:
94
100
  message: dict[str, Any]
95
101
  meta: Any
96
102
  ack: Callable[[], None]
103
+ run_id: str
97
104
  future: "asyncio.Future[Any]" = field(default_factory=_future)
98
105
 
99
106
 
@@ -102,6 +109,7 @@ class _Reload:
102
109
  source_meta: dict[str, Any]
103
110
  meta: Any
104
111
  ack: Callable[[], None]
112
+ run_id: str
105
113
  future: "asyncio.Future[Any]" = field(default_factory=_future)
106
114
 
107
115
 
@@ -131,6 +139,7 @@ class _Rewind:
131
139
  rollback_to: Any
132
140
  ack: Callable[[], None]
133
141
  future: "asyncio.Future[Any]"
142
+ run_id: str
134
143
  root_meta: Any = None
135
144
 
136
145
 
@@ -139,7 +148,7 @@ class _Effects:
139
148
  steer_added: bool = False
140
149
  continue_requested: bool = False
141
150
  continue_meta: Any = None
142
- staged_sends: list[tuple["asyncio.Future[Any]", Callable[[], None]]] = field(
151
+ staged_sends: list[tuple["asyncio.Future[Any]", Callable[[], None], Any]] = field(
143
152
  default_factory=list
144
153
  )
145
154
  continues: list["asyncio.Future[Any]"] = field(default_factory=list)
@@ -195,8 +204,6 @@ class RunManager:
195
204
  self._stop_reason: str | None = None
196
205
  self._staged_rewinds: list[_Rewind] = []
197
206
  self._dispatching: list[tuple[str, dict[str, Any]]] = []
198
- self._run_acked = False
199
- self._run_futures: list["asyncio.Future[Any]"] = []
200
207
  self._input_requests: list[dict[str, Any]] = []
201
208
  self._input_answers: dict[str, Any] = {}
202
209
  self._idle = asyncio.Event()
@@ -302,6 +309,9 @@ class RunManager:
302
309
  self._clear_input()
303
310
  if self._dispatching:
304
311
  self._revert_dispatching()
312
+ entry = self._ensure_entry()
313
+ if entry["runId"] is None:
314
+ entry["runId"] = rewind.run_id
305
315
  self._dispatch(
306
316
  rewind.type,
307
317
  rewind.messages,
@@ -310,7 +320,7 @@ class RunManager:
310
320
  )
311
321
  rewind.ack()
312
322
  if not rewind.future.done():
313
- self._run_futures.append(rewind.future)
323
+ rewind.future.set_result(None)
314
324
  elif outcome is not None:
315
325
  self._settle_outcome(outcome)
316
326
  else:
@@ -318,10 +328,10 @@ class RunManager:
318
328
  if self._task is not None and (self._staged_stops or self._staged_rewinds):
319
329
  assert self._ctx is not None
320
330
  self._ctx.stop_requested.set()
321
- for future, ack in fx.staged_sends:
331
+ for future, ack, result in fx.staged_sends:
322
332
  ack()
323
333
  if not future.done():
324
- future.set_result(None)
334
+ future.set_result(result)
325
335
  for future in fx.continues:
326
336
  if not future.done():
327
337
  future.set_result(None)
@@ -364,8 +374,6 @@ class RunManager:
364
374
  elif status in ("error", "stopped"):
365
375
  if fx.continue_requested or fx.steer_added:
366
376
  self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
367
- self._run_futures.extend(fx.continues)
368
- fx.continues.clear()
369
377
  elif self._dispatching:
370
378
  self._dispatch_staged()
371
379
  elif status == "input-required":
@@ -398,7 +406,6 @@ class RunManager:
398
406
  if outcome.dispatch_queue and self._pop_dispatchable():
399
407
  return
400
408
  self._set_status(status)
401
- self._entry()["runId"] = None
402
409
 
403
410
  # ─── Dispatch and settle ────────────────────────────────
404
411
 
@@ -445,13 +452,11 @@ class RunManager:
445
452
  ]
446
453
  self._dispatch_record = dict(record)
447
454
  self._stop_reason = None
448
- self._run_acked = False
449
455
  entry = self._ensure_entry()
450
456
  entry["dispatching"] = record
451
457
  entry["error"] = None
452
458
  if messages:
453
459
  self._dispatched_ids = tuple(m["id"] for m in messages)
454
- entry["runId"] = uuid.uuid4().hex
455
460
  self._set_status("running")
456
461
  ctx = RunManager.RunContext(
457
462
  trigger=trigger,
@@ -487,10 +492,6 @@ class RunManager:
487
492
  except asyncio.CancelledError:
488
493
  self._settle(ctx)
489
494
  self._set_status("stopped")
490
- self._entry()["runId"] = None
491
- self._settle_initiators(
492
- None if self._run_acked else _reject("stopped", "run cancelled")
493
- )
494
495
  self._revert_dispatching()
495
496
  self._idle.set()
496
497
  raise # no drain, no freeze
@@ -499,25 +500,12 @@ class RunManager:
499
500
  message = str(exc) or type(exc).__name__
500
501
  if isinstance(exc, StatewireReject):
501
502
  self._freeze(exc.message, exc.payload)
502
- self._settle_initiators(None if self._run_acked else exc)
503
503
  else:
504
504
  self._freeze(message)
505
- self._settle_initiators(
506
- None if self._run_acked else _reject("run-error", message)
507
- )
508
505
  self._revert_dispatching()
509
506
  self._drain()
510
507
  return
511
508
  self._settle(ctx)
512
- if self._run_acked:
513
- error = None
514
- elif isinstance(outcome, RunManager.Error):
515
- error = _reject("run-error", "run ended in error")
516
- elif isinstance(outcome, RunManager.Stop):
517
- error = _reject("stopped", "run stopped before the ack")
518
- else:
519
- error = None
520
- self._settle_initiators(error)
521
509
  self._revert_dispatching()
522
510
  self._outcome = outcome
523
511
  self._drain()
@@ -528,16 +516,6 @@ class RunManager:
528
516
  self._task = None
529
517
  self._dispatch_record = None
530
518
 
531
- def _settle_initiators(self, error: StatewireReject | None) -> None:
532
- futures, self._run_futures = self._run_futures, []
533
- for future in futures:
534
- if future.done():
535
- continue
536
- if error is None:
537
- future.set_result(None)
538
- else:
539
- future.set_exception(error)
540
-
541
519
  def _ack(self) -> None:
542
520
  entry = self._entry()
543
521
  record = plain(entry["dispatching"])
@@ -545,7 +523,6 @@ class RunManager:
545
523
  raise RuntimeError("ack() with no unacked batch")
546
524
  self._dispatching = []
547
525
  entry["dispatching"] = None
548
- self._run_acked = True
549
526
 
550
527
  def _revert_dispatching(self) -> None:
551
528
  taken, self._dispatching = self._dispatching, []
@@ -621,7 +598,6 @@ class RunManager:
621
598
  def _freeze(self, message: str, payload: Any = None) -> None:
622
599
  self._set_status("error")
623
600
  entry = self._entry()
624
- entry["runId"] = None
625
601
  if isinstance(payload, dict):
626
602
  entry["error"] = {**payload, "message": message}
627
603
  elif payload is not None:
@@ -761,6 +737,20 @@ class RunManager:
761
737
  if not e.anchor_meta["onActiveBranch"]:
762
738
  raise _reject("wrong-anchor", f"anchor {e.anchor} is off the active branch")
763
739
 
740
+ def _check_rewind_anchor(self, params: Any, source_meta: dict[str, Any]) -> None:
741
+ anchor = params.get("runAnchorMessageId", _ABSENT)
742
+ if anchor is _ABSENT:
743
+ raise _reject("invalid-message", "runAnchorMessageId is required")
744
+ if anchor is not None and not isinstance(anchor, str):
745
+ raise _reject(
746
+ "invalid-message", "runAnchorMessageId must be an id or null"
747
+ )
748
+ if anchor != source_meta["parentId"]:
749
+ raise _reject(
750
+ "wrong-anchor",
751
+ f"runAnchorMessageId {anchor!r} is not the source's parent",
752
+ )
753
+
764
754
  # ─── Queue mutations ────────────────────────────────────
765
755
 
766
756
  def _stamped(self, message: dict[str, Any], meta: Any) -> dict[str, Any]:
@@ -829,16 +819,28 @@ class RunManager:
829
819
  return self._park_dispatched_edit(e)
830
820
  if e.source_meta is not None:
831
821
  raise _reject("duplicate-id", f"message id {e.message_id} is already used")
822
+ entry = self._entry()
823
+ live_run_id = plain(entry["runId"]) if entry is not None else None
832
824
  if e.anchor is _ABSENT:
833
- raise _reject("invalid-message", "anchorMessageId is required")
834
- self._check_anchor(e)
825
+ if live_run_id is None:
826
+ raise _reject("invalid-message", "runAnchorMessageId is required")
827
+ else:
828
+ self._check_anchor(e)
835
829
  if self._dispatchable(e.lane):
836
830
  self._stage_dispatch(e)
837
831
  else:
838
832
  self._insert_new(e.lane, e.message, e.params, e.meta)
839
833
  if e.lane == "steerQueue":
840
834
  fx.steer_added = True
841
- fx.staged_sends.append((e.future, e.ack))
835
+ if live_run_id is None:
836
+ self._ensure_entry()["runId"] = e.run_id
837
+ # A mint racing a live run merges into it; settle carries the surviving id.
838
+ result = (
839
+ {"runId": live_run_id}
840
+ if live_run_id is not None and e.anchor is not _ABSENT
841
+ else None
842
+ )
843
+ fx.staged_sends.append((e.future, e.ack, result))
842
844
  return _PARKED
843
845
 
844
846
  def _apply_move(self, e: _Send, fx: _Effects) -> Any:
@@ -880,6 +882,7 @@ class RunManager:
880
882
  e.source_meta["parentId"],
881
883
  e.ack,
882
884
  e.future,
885
+ e.run_id,
883
886
  )
884
887
  )
885
888
  return _PARKED
@@ -931,6 +934,7 @@ class RunManager:
931
934
  e.source_meta["parentId"],
932
935
  e.ack,
933
936
  e.future,
937
+ e.run_id,
934
938
  )
935
939
  )
936
940
  return _PARKED
@@ -943,6 +947,7 @@ class RunManager:
943
947
  e.source_meta["parentId"],
944
948
  e.ack,
945
949
  e.future,
950
+ e.run_id,
946
951
  root_meta=e.meta,
947
952
  )
948
953
  )
@@ -959,13 +964,14 @@ class RunManager:
959
964
  ) -> Any:
960
965
  if not isinstance(params, dict):
961
966
  raise _reject("invalid-message", "params must be an object")
967
+ run_id = _run_id_of(params)
962
968
  has_message = "message" in params
963
969
  has_message_id = "messageId" in params
964
970
  if has_message == has_message_id:
965
971
  raise _reject(
966
972
  "invalid-message", "exactly one of message and messageId is required"
967
973
  )
968
- anchor = params.get("anchorMessageId", _ABSENT)
974
+ anchor = params.get("runAnchorMessageId", _ABSENT)
969
975
  anchor_meta: dict[str, Any] | None = None
970
976
  thread_empty = False
971
977
  if anchor is None:
@@ -975,7 +981,7 @@ class RunManager:
975
981
  elif anchor is not _ABSENT:
976
982
  if not isinstance(anchor, str):
977
983
  raise _reject(
978
- "invalid-message", "anchorMessageId must be an id or null"
984
+ "invalid-message", "runAnchorMessageId must be an id or null"
979
985
  )
980
986
  anchor_meta = await self._thread.get_message_meta(anchor)
981
987
  if not has_message:
@@ -991,6 +997,7 @@ class RunManager:
991
997
  None,
992
998
  None,
993
999
  ack,
1000
+ run_id=run_id,
994
1001
  anchor=anchor,
995
1002
  anchor_meta=anchor_meta,
996
1003
  thread_empty=thread_empty,
@@ -1007,6 +1014,7 @@ class RunManager:
1007
1014
  source_meta,
1008
1015
  meta,
1009
1016
  ack,
1017
+ run_id=run_id,
1010
1018
  anchor=anchor,
1011
1019
  anchor_meta=anchor_meta,
1012
1020
  thread_empty=thread_empty,
@@ -1043,6 +1051,7 @@ class RunManager:
1043
1051
  self, params: Any, *, meta: Any = None, ack: Callable[[], None]
1044
1052
  ) -> Any:
1045
1053
  self._check_rewind_gate("run/edit")
1054
+ run_id = _run_id_of(params)
1046
1055
  source_id = params.get("sourceId") if isinstance(params, dict) else None
1047
1056
  if not isinstance(source_id, str):
1048
1057
  raise _reject("invalid-message", "sourceId must be a string")
@@ -1053,6 +1062,7 @@ class RunManager:
1053
1062
  raise _reject(
1054
1063
  "capability-missing", "the assistant-edit capability is not enabled"
1055
1064
  )
1065
+ self._check_rewind_anchor(params, source_meta)
1056
1066
  message = self._validated_message(
1057
1067
  params.get("message") if isinstance(params, dict) else None
1058
1068
  )
@@ -1061,12 +1071,15 @@ class RunManager:
1061
1071
  and await self._thread.get_message_meta(message["id"]) is not None
1062
1072
  ):
1063
1073
  raise _reject("duplicate-id", f"message id {message['id']} is already used")
1064
- return await self._stage(_Edit(source_id, source_meta, message, meta, ack))
1074
+ return await self._stage(
1075
+ _Edit(source_id, source_meta, message, meta, ack, run_id)
1076
+ )
1065
1077
 
1066
1078
  async def reload(
1067
1079
  self, params: Any, *, meta: Any = None, ack: Callable[[], None]
1068
1080
  ) -> Any:
1069
1081
  self._check_rewind_gate("run/reload")
1082
+ run_id = _run_id_of(params)
1070
1083
  source_id = params.get("sourceId") if isinstance(params, dict) else None
1071
1084
  if not isinstance(source_id, str):
1072
1085
  raise _reject("invalid-message", "sourceId must be a string")
@@ -1075,6 +1088,7 @@ class RunManager:
1075
1088
  raise _reject("unknown-id", f"message {source_id} is unknown")
1076
1089
  if source_meta["role"] != "assistant":
1077
1090
  raise _reject("invalid-message", "sourceId must name an assistant message")
1091
+ self._check_rewind_anchor(params, source_meta)
1078
1092
  if source_meta["parentId"] is not None:
1079
1093
  parent = await self._thread.get_message_meta(source_meta["parentId"])
1080
1094
  if (
@@ -1086,7 +1100,7 @@ class RunManager:
1086
1100
  "capability-missing",
1087
1101
  "the assistant-continuation capability is not enabled",
1088
1102
  )
1089
- return await self._stage(_Reload(source_meta, meta, ack))
1103
+ return await self._stage(_Reload(source_meta, meta, ack, run_id))
1090
1104
 
1091
1105
  async def stop(self, params: Any = None, *, ack: Callable[[], None]) -> Any:
1092
1106
  if params is not None and not isinstance(params, dict):
@@ -1097,7 +1111,7 @@ class RunManager:
1097
1111
  status = self._status()
1098
1112
  if status != "running":
1099
1113
  raise _reject("wrong-state", f"run/stop is rejected in {status}")
1100
- run_id = (params or {}).get("runId")
1114
+ run_id = _run_id_of(params)
1101
1115
  if run_id != plain(self._entry()["runId"]):
1102
1116
  raise _reject(
1103
1117
  "wrong-state", f"runId {run_id!r} does not name the live run"