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.
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/.gitignore +2 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/PKG-INFO +2 -2
- harness_sdk_python-0.10.1/examples/__init__.py +0 -0
- harness_sdk_python-0.10.1/examples/runs_app.py +203 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/pyproject.toml +2 -2
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/run_manager.py +68 -54
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/run_helpers.py +60 -3
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_ack_visibility.py +2 -2
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_batches.py +5 -2
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_branch_anchor.py +46 -6
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_dispatching.py +2 -2
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_edit_dispatched.py +6 -6
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_edit_reload.py +107 -19
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_enqueue.py +13 -12
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_facade.py +18 -11
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_input_required.py +15 -4
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_meta.py +5 -5
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_placement.py +4 -4
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_prepare_hooks.py +10 -18
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_rewind_during_run.py +18 -9
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_settle.py +27 -30
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_steer.py +6 -6
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_stop_continue.py +36 -24
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/README.md +0 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.9.0 → harness_sdk_python-0.10.1}/tests/test_outcomes.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
12
|
-
``dispatching``)
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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("
|
|
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", "
|
|
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(
|
|
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
|
|
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"
|