harness-sdk-python 0.1.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
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""RunManager: the harness-sdk runs subsystem over a Statewire host.
|
|
2
|
+
|
|
3
|
+
Implements the full state x command matrix from runs.mdx on a single
|
|
4
|
+
``start(ctx)`` executor entrypoint, per the RunManager design (d4987).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Awaitable, Callable, Iterable
|
|
10
|
+
|
|
11
|
+
from statewire import CommandExecution, StatewireClientHandle, StatewireReject
|
|
12
|
+
from statewire.state import plain
|
|
13
|
+
|
|
14
|
+
_ABSENT: Any = object()
|
|
15
|
+
|
|
16
|
+
_CAPABILITIES = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"files",
|
|
19
|
+
"adjacent-text-parts",
|
|
20
|
+
"interleaved-parts",
|
|
21
|
+
"rewind",
|
|
22
|
+
"rewind-during-run",
|
|
23
|
+
"assistant-edit",
|
|
24
|
+
"assistant-continuation",
|
|
25
|
+
"incomplete-continuation",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_ENTRY_TYPES = (
|
|
30
|
+
"message-send",
|
|
31
|
+
"message-edit",
|
|
32
|
+
"message-reload",
|
|
33
|
+
"input-resume",
|
|
34
|
+
"error-continue",
|
|
35
|
+
"stop-continue",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
GetMessageMeta = Callable[[str], Awaitable[dict[str, Any] | None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _reject(reason: str, message: str) -> StatewireReject:
|
|
42
|
+
return StatewireReject(message, payload={"reason": reason})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RunManager:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
state: Any,
|
|
50
|
+
start: Callable[["RunManager.StartContext"], Awaitable[Any]],
|
|
51
|
+
get_message_meta: GetMessageMeta,
|
|
52
|
+
create_task: Callable[[Any], "asyncio.Task[Any]"],
|
|
53
|
+
capabilities: Iterable[str] = (),
|
|
54
|
+
max_queued: int = 50,
|
|
55
|
+
) -> None:
|
|
56
|
+
caps = frozenset(capabilities)
|
|
57
|
+
unknown = caps - _CAPABILITIES
|
|
58
|
+
if unknown:
|
|
59
|
+
raise ValueError(f"unknown capabilities: {sorted(unknown)}")
|
|
60
|
+
if "rewind-during-run" in caps and "rewind" not in caps:
|
|
61
|
+
raise ValueError("rewind-during-run requires the rewind capability")
|
|
62
|
+
if max_queued < 1:
|
|
63
|
+
raise ValueError("max_queued must be >= 1")
|
|
64
|
+
self._state = state
|
|
65
|
+
self._start = start
|
|
66
|
+
self._get_message_meta = get_message_meta
|
|
67
|
+
self._capabilities = caps
|
|
68
|
+
self._create_task = create_task
|
|
69
|
+
self._max_queued = max_queued
|
|
70
|
+
self._task: "asyncio.Task[Any] | None" = None
|
|
71
|
+
self._ctx: "RunManager.StartContext | None" = None
|
|
72
|
+
self._dispatched_ids: tuple[str, ...] = ()
|
|
73
|
+
self._dispatch_record: dict[str, Any] | None = None
|
|
74
|
+
self._callers: dict[str, StatewireClientHandle] = {}
|
|
75
|
+
self._init_state()
|
|
76
|
+
|
|
77
|
+
def _init_state(self) -> None:
|
|
78
|
+
# Runs state is write-only and not durable: overwrite whatever is there.
|
|
79
|
+
self._state["status"] = "ready"
|
|
80
|
+
self._state["error"] = None
|
|
81
|
+
self._state["queue"] = []
|
|
82
|
+
self._state["steerQueue"] = []
|
|
83
|
+
self._state.pop("dispatch", None)
|
|
84
|
+
|
|
85
|
+
def input_resume(self) -> None:
|
|
86
|
+
if self._status() != "input-required":
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
f"input_resume is only valid in input-required, not {self._status()!r}"
|
|
89
|
+
)
|
|
90
|
+
self._dispatch("input-resume", [])
|
|
91
|
+
|
|
92
|
+
# ─── State access ───────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def _status(self) -> str:
|
|
95
|
+
return self._state["status"]
|
|
96
|
+
|
|
97
|
+
def _lane_items(self, lane: str) -> list[dict[str, Any]]:
|
|
98
|
+
return list(plain(self._state[lane]))
|
|
99
|
+
|
|
100
|
+
def _lane_of(self, message_id: str) -> str | None:
|
|
101
|
+
for lane in ("steerQueue", "queue"):
|
|
102
|
+
if any(item["id"] == message_id for item in self._lane_items(lane)):
|
|
103
|
+
return lane
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _continue_type(self) -> str:
|
|
107
|
+
return "error-continue" if self._status() == "error" else "stop-continue"
|
|
108
|
+
|
|
109
|
+
# ─── Dispatch and settle ────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def _dispatch(
|
|
112
|
+
self,
|
|
113
|
+
type: str,
|
|
114
|
+
messages: list[dict[str, Any]],
|
|
115
|
+
*,
|
|
116
|
+
rollback_to: Any = _ABSENT,
|
|
117
|
+
) -> None:
|
|
118
|
+
if type not in _ENTRY_TYPES:
|
|
119
|
+
raise ValueError(f"invalid entry type: {type!r}")
|
|
120
|
+
if self._task is not None:
|
|
121
|
+
raise RuntimeError("a run is already in flight")
|
|
122
|
+
caller = self._callers.get(messages[0]["id"]) if messages else None
|
|
123
|
+
messages = [
|
|
124
|
+
{k: v for k, v in message.items() if k != "caller"} for message in messages
|
|
125
|
+
]
|
|
126
|
+
for message in messages:
|
|
127
|
+
self._callers.pop(message["id"], None)
|
|
128
|
+
record: dict[str, Any] = {"type": type, "messages": list(messages)}
|
|
129
|
+
if rollback_to is not _ABSENT:
|
|
130
|
+
record["rollbackTo"] = rollback_to
|
|
131
|
+
self._dispatch_record = record
|
|
132
|
+
self._state["error"] = None
|
|
133
|
+
if messages:
|
|
134
|
+
self._dispatched_ids = tuple(m["id"] for m in messages)
|
|
135
|
+
self._state["status"] = "running"
|
|
136
|
+
ctx = RunManager.StartContext(
|
|
137
|
+
type=type,
|
|
138
|
+
messages=tuple(messages),
|
|
139
|
+
caller=caller,
|
|
140
|
+
stop_requested=asyncio.Event(),
|
|
141
|
+
_manager=self,
|
|
142
|
+
_rollback_to=rollback_to,
|
|
143
|
+
)
|
|
144
|
+
self._ctx = ctx
|
|
145
|
+
self._task = self._create_task(self._run(ctx))
|
|
146
|
+
|
|
147
|
+
async def _run(self, ctx: "RunManager.StartContext") -> None:
|
|
148
|
+
try:
|
|
149
|
+
outcome = await self._start(ctx)
|
|
150
|
+
if not isinstance(
|
|
151
|
+
outcome,
|
|
152
|
+
(
|
|
153
|
+
RunManager.Complete,
|
|
154
|
+
RunManager.InputRequired,
|
|
155
|
+
RunManager.Error,
|
|
156
|
+
RunManager.Stop,
|
|
157
|
+
),
|
|
158
|
+
):
|
|
159
|
+
raise TypeError(
|
|
160
|
+
"start must return a RunManager outcome, got "
|
|
161
|
+
f"{type(outcome).__name__}"
|
|
162
|
+
)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
self._settle(ctx)
|
|
165
|
+
self._freeze(str(exc) or type(exc).__name__)
|
|
166
|
+
return
|
|
167
|
+
self._settle(ctx)
|
|
168
|
+
if isinstance(outcome, RunManager.Complete):
|
|
169
|
+
self._dispatched_ids = ()
|
|
170
|
+
if not self._drain():
|
|
171
|
+
self._state["status"] = "ready"
|
|
172
|
+
return
|
|
173
|
+
if isinstance(outcome, RunManager.InputRequired):
|
|
174
|
+
self._state["status"] = "input-required"
|
|
175
|
+
return
|
|
176
|
+
status = "error" if isinstance(outcome, RunManager.Error) else "stopped"
|
|
177
|
+
if outcome.dispatch_queue and self._drain():
|
|
178
|
+
return
|
|
179
|
+
self._state["status"] = status
|
|
180
|
+
|
|
181
|
+
def _settle(self, ctx: "RunManager.StartContext") -> None:
|
|
182
|
+
if self._ctx is ctx:
|
|
183
|
+
self._ctx = None
|
|
184
|
+
self._task = None
|
|
185
|
+
self._dispatch_record = None
|
|
186
|
+
|
|
187
|
+
def _drain(self) -> bool:
|
|
188
|
+
steer = self._lane_items("steerQueue")
|
|
189
|
+
if steer:
|
|
190
|
+
self._state["steerQueue"] = []
|
|
191
|
+
self._dispatch("message-send", steer)
|
|
192
|
+
return True
|
|
193
|
+
queue = self._lane_items("queue")
|
|
194
|
+
if queue:
|
|
195
|
+
self._state["queue"].pop(0)
|
|
196
|
+
self._dispatch("message-send", [queue[0]])
|
|
197
|
+
return True
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
def _freeze(self, message: str) -> None:
|
|
201
|
+
self._state["status"] = "error"
|
|
202
|
+
self._state["error"] = {"message": message}
|
|
203
|
+
|
|
204
|
+
# ─── Message and placement validation ───────────────────
|
|
205
|
+
|
|
206
|
+
def _validated_message(self, message: Any) -> dict[str, Any]:
|
|
207
|
+
if not isinstance(message, dict):
|
|
208
|
+
raise _reject("invalid-message", "message must be an object")
|
|
209
|
+
if not isinstance(message.get("id"), str) or message["id"] == "":
|
|
210
|
+
raise _reject("invalid-message", "message.id must be a non-empty string")
|
|
211
|
+
if message.get("role") != "user":
|
|
212
|
+
raise _reject("invalid-message", 'message.role must be "user"')
|
|
213
|
+
parts = message.get("parts")
|
|
214
|
+
if not isinstance(parts, list) or len(parts) == 0:
|
|
215
|
+
raise _reject("invalid-message", "message.parts must be a non-empty array")
|
|
216
|
+
previous: str | None = None
|
|
217
|
+
seen_text = False
|
|
218
|
+
for part in parts:
|
|
219
|
+
kind = part.get("type") if isinstance(part, dict) else None
|
|
220
|
+
if kind == "text":
|
|
221
|
+
if not isinstance(part.get("text"), str):
|
|
222
|
+
raise _reject("invalid-message", "text parts must carry a string text")
|
|
223
|
+
if previous == "text" and "adjacent-text-parts" not in self._capabilities:
|
|
224
|
+
raise _reject(
|
|
225
|
+
"invalid-message",
|
|
226
|
+
"adjacent text parts require the adjacent-text-parts capability",
|
|
227
|
+
)
|
|
228
|
+
seen_text = True
|
|
229
|
+
elif kind == "file":
|
|
230
|
+
if "files" not in self._capabilities:
|
|
231
|
+
raise _reject("capability-missing", "the files capability is not enabled")
|
|
232
|
+
if not isinstance(part.get("mediaType"), str) or not isinstance(
|
|
233
|
+
part.get("url"), str
|
|
234
|
+
):
|
|
235
|
+
raise _reject(
|
|
236
|
+
"invalid-message", "file parts must carry mediaType and url"
|
|
237
|
+
)
|
|
238
|
+
if seen_text and "interleaved-parts" not in self._capabilities:
|
|
239
|
+
raise _reject(
|
|
240
|
+
"invalid-message",
|
|
241
|
+
"file parts after text require the interleaved-parts capability",
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
raise _reject("invalid-message", f"unknown part type: {kind!r}")
|
|
245
|
+
previous = kind
|
|
246
|
+
metadata = message.get("metadata", _ABSENT)
|
|
247
|
+
if metadata is not _ABSENT and not isinstance(metadata, dict):
|
|
248
|
+
raise _reject("invalid-message", "message.metadata must be an object")
|
|
249
|
+
return message
|
|
250
|
+
|
|
251
|
+
def _anchor_index(self, items: list[dict[str, Any]], anchor: str) -> int:
|
|
252
|
+
for index, item in enumerate(items):
|
|
253
|
+
if item["id"] == anchor:
|
|
254
|
+
return index
|
|
255
|
+
raise _reject("unknown-anchor", f"anchor {anchor} is not in the lane")
|
|
256
|
+
|
|
257
|
+
def _resolve_index(
|
|
258
|
+
self, items: list[dict[str, Any]], placement: dict[str, Any], fallback: int
|
|
259
|
+
) -> int:
|
|
260
|
+
insert_after = placement.get("insertAfter", _ABSENT)
|
|
261
|
+
insert_before = placement.get("insertBefore", _ABSENT)
|
|
262
|
+
for name, value in (("insertAfter", insert_after), ("insertBefore", insert_before)):
|
|
263
|
+
if value is not _ABSENT and value is not None and not isinstance(value, str):
|
|
264
|
+
raise _reject("invalid-message", f"{name} must be an id or null")
|
|
265
|
+
if insert_before is _ABSENT:
|
|
266
|
+
if insert_after is _ABSENT:
|
|
267
|
+
return fallback
|
|
268
|
+
return 0 if insert_after is None else self._anchor_index(items, insert_after) + 1
|
|
269
|
+
if insert_after is _ABSENT:
|
|
270
|
+
return (
|
|
271
|
+
len(items)
|
|
272
|
+
if insert_before is None
|
|
273
|
+
else self._anchor_index(items, insert_before)
|
|
274
|
+
)
|
|
275
|
+
slot = 0 if insert_after is None else self._anchor_index(items, insert_after) + 1
|
|
276
|
+
if insert_before is not None:
|
|
277
|
+
self._anchor_index(items, insert_before)
|
|
278
|
+
at_slot = items[slot]["id"] if slot < len(items) else None
|
|
279
|
+
if at_slot != insert_before:
|
|
280
|
+
raise _reject("not-adjacent", "insertAfter and insertBefore are not adjacent")
|
|
281
|
+
return slot
|
|
282
|
+
|
|
283
|
+
# ─── Queue mutations ────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def _stamped(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
286
|
+
entry = {k: v for k, v in message.items() if k != "caller"}
|
|
287
|
+
caller = self._callers.get(message["id"])
|
|
288
|
+
if caller is not None:
|
|
289
|
+
entry["caller"] = {"clientId": caller.client_id}
|
|
290
|
+
return entry
|
|
291
|
+
|
|
292
|
+
def _check_capacity(self, lane: str) -> None:
|
|
293
|
+
if len(self._lane_items(lane)) >= self._max_queued:
|
|
294
|
+
raise _reject(
|
|
295
|
+
"queue-full", f"queue is full ({self._max_queued} messages)"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _place(
|
|
299
|
+
self,
|
|
300
|
+
target: str,
|
|
301
|
+
current: str,
|
|
302
|
+
message: dict[str, Any],
|
|
303
|
+
placement: dict[str, Any],
|
|
304
|
+
) -> None:
|
|
305
|
+
lane_change = current != target
|
|
306
|
+
if lane_change:
|
|
307
|
+
self._check_capacity(target)
|
|
308
|
+
current_items = self._lane_items(current)
|
|
309
|
+
current_index = next(
|
|
310
|
+
i for i, item in enumerate(current_items) if item["id"] == message["id"]
|
|
311
|
+
)
|
|
312
|
+
without = [item for item in current_items if item["id"] != message["id"]]
|
|
313
|
+
base = self._lane_items(target) if lane_change else without
|
|
314
|
+
index = self._resolve_index(
|
|
315
|
+
base, placement, len(base) if lane_change else current_index
|
|
316
|
+
)
|
|
317
|
+
items = list(base)
|
|
318
|
+
items.insert(index, self._stamped(message))
|
|
319
|
+
if lane_change:
|
|
320
|
+
self._state[current] = without
|
|
321
|
+
self._state[target] = items
|
|
322
|
+
if target == "steerQueue" and self._status() in ("error", "stopped"):
|
|
323
|
+
self._dispatch(self._continue_type(), [])
|
|
324
|
+
|
|
325
|
+
def _add_new(
|
|
326
|
+
self, lane: str, message: dict[str, Any], placement: dict[str, Any]
|
|
327
|
+
) -> None:
|
|
328
|
+
status = self._status()
|
|
329
|
+
if status == "ready":
|
|
330
|
+
self._dispatch("message-send", [message])
|
|
331
|
+
return
|
|
332
|
+
if status in ("error", "stopped"):
|
|
333
|
+
if lane == "steerQueue":
|
|
334
|
+
self._check_capacity(lane)
|
|
335
|
+
items = self._lane_items(lane)
|
|
336
|
+
items.insert(
|
|
337
|
+
self._resolve_index(items, placement, len(items)),
|
|
338
|
+
self._stamped(message),
|
|
339
|
+
)
|
|
340
|
+
self._state[lane] = items
|
|
341
|
+
self._dispatch(self._continue_type(), [])
|
|
342
|
+
return
|
|
343
|
+
if not self._lane_items("steerQueue") and not self._lane_items("queue"):
|
|
344
|
+
self._dispatch("message-send", [message])
|
|
345
|
+
return
|
|
346
|
+
self._check_capacity(lane)
|
|
347
|
+
items = self._lane_items(lane)
|
|
348
|
+
items.insert(
|
|
349
|
+
self._resolve_index(items, placement, len(items)), self._stamped(message)
|
|
350
|
+
)
|
|
351
|
+
self._state[lane] = items
|
|
352
|
+
|
|
353
|
+
async def _move(self, lane: str, message_id: Any, placement: dict[str, Any]) -> None:
|
|
354
|
+
if not isinstance(message_id, str):
|
|
355
|
+
raise _reject("invalid-message", "messageId must be a string")
|
|
356
|
+
current = self._lane_of(message_id)
|
|
357
|
+
if current is None:
|
|
358
|
+
if message_id in self._dispatched_ids:
|
|
359
|
+
if lane == "steerQueue":
|
|
360
|
+
return
|
|
361
|
+
raise _reject(
|
|
362
|
+
"already-dispatched", f"message {message_id} already dispatched"
|
|
363
|
+
)
|
|
364
|
+
raise _reject("unknown-id", f"message {message_id} is not queued")
|
|
365
|
+
item = next(i for i in self._lane_items(current) if i["id"] == message_id)
|
|
366
|
+
self._place(lane, current, item, placement)
|
|
367
|
+
|
|
368
|
+
async def _send(
|
|
369
|
+
self, lane: str, params: Any, caller: StatewireClientHandle | None
|
|
370
|
+
) -> Any:
|
|
371
|
+
if not isinstance(params, dict):
|
|
372
|
+
raise _reject("invalid-message", "params must be an object")
|
|
373
|
+
has_message = "message" in params
|
|
374
|
+
has_message_id = "messageId" in params
|
|
375
|
+
if has_message == has_message_id:
|
|
376
|
+
raise _reject(
|
|
377
|
+
"invalid-message", "exactly one of message and messageId is required"
|
|
378
|
+
)
|
|
379
|
+
if not has_message:
|
|
380
|
+
return await self._move(lane, params["messageId"], params)
|
|
381
|
+
message = self._validated_message(params["message"])
|
|
382
|
+
message_id = message["id"]
|
|
383
|
+
previous = self._callers.get(message_id)
|
|
384
|
+
if caller is None:
|
|
385
|
+
self._callers.pop(message_id, None)
|
|
386
|
+
else:
|
|
387
|
+
self._callers[message_id] = caller
|
|
388
|
+
try:
|
|
389
|
+
current = self._lane_of(message_id)
|
|
390
|
+
if current is not None:
|
|
391
|
+
self._place(lane, current, message, params)
|
|
392
|
+
return None
|
|
393
|
+
if message_id in self._dispatched_ids:
|
|
394
|
+
return await self._edit_dispatched(message)
|
|
395
|
+
if await self._get_message_meta(message_id) is not None:
|
|
396
|
+
raise _reject("duplicate-id", f"message id {message_id} is already used")
|
|
397
|
+
self._add_new(lane, message, params)
|
|
398
|
+
return None
|
|
399
|
+
except StatewireReject:
|
|
400
|
+
if previous is None:
|
|
401
|
+
self._callers.pop(message_id, None)
|
|
402
|
+
else:
|
|
403
|
+
self._callers[message_id] = previous
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
async def _edit_dispatched(self, message: dict[str, Any]) -> Any:
|
|
407
|
+
if self._task is not None:
|
|
408
|
+
if "rewind-during-run" not in self._capabilities:
|
|
409
|
+
raise _reject(
|
|
410
|
+
"capability-missing",
|
|
411
|
+
"editing during a run requires the rewind-during-run capability",
|
|
412
|
+
)
|
|
413
|
+
elif "rewind" not in self._capabilities:
|
|
414
|
+
raise _reject("capability-missing", "the rewind capability is not enabled")
|
|
415
|
+
meta = await self._get_message_meta(message["id"])
|
|
416
|
+
if meta is None:
|
|
417
|
+
raise _reject("unknown-id", f"message {message['id']} is unknown")
|
|
418
|
+
if self._task is not None and not meta["isLeaf"]:
|
|
419
|
+
raise _reject("not-leaf", "only the leaf may be edited during a run")
|
|
420
|
+
return self._rewind_dispatch("message-edit", [message], meta["parentId"])
|
|
421
|
+
|
|
422
|
+
def _rewind_dispatch(
|
|
423
|
+
self, type: str, messages: list[dict[str, Any]], rollback_to: Any
|
|
424
|
+
) -> Any:
|
|
425
|
+
if self._task is None:
|
|
426
|
+
self._dispatch(type, messages, rollback_to=rollback_to)
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
async def flow(ack: Callable[[], None]) -> None:
|
|
430
|
+
ack()
|
|
431
|
+
while self._task is not None:
|
|
432
|
+
assert self._ctx is not None
|
|
433
|
+
self._ctx.stop_requested.set()
|
|
434
|
+
await self._task
|
|
435
|
+
self._dispatch(type, messages, rollback_to=rollback_to)
|
|
436
|
+
|
|
437
|
+
return CommandExecution(flow)
|
|
438
|
+
|
|
439
|
+
# ─── Command handlers ───────────────────────────────────
|
|
440
|
+
|
|
441
|
+
async def enqueue(
|
|
442
|
+
self, params: Any, *, caller: StatewireClientHandle | None = None
|
|
443
|
+
) -> Any:
|
|
444
|
+
return await self._send("queue", params, caller)
|
|
445
|
+
|
|
446
|
+
async def steer(
|
|
447
|
+
self, params: Any, *, caller: StatewireClientHandle | None = None
|
|
448
|
+
) -> Any:
|
|
449
|
+
return await self._send("steerQueue", params, caller)
|
|
450
|
+
|
|
451
|
+
async def dequeue(self, params: Any) -> None:
|
|
452
|
+
message_id = params.get("messageId") if isinstance(params, dict) else None
|
|
453
|
+
if not isinstance(message_id, str):
|
|
454
|
+
raise _reject("invalid-message", "messageId must be a string")
|
|
455
|
+
lane = self._lane_of(message_id)
|
|
456
|
+
if lane is None:
|
|
457
|
+
raise _reject("unknown-id", f"message {message_id} is not queued")
|
|
458
|
+
self._callers.pop(message_id, None)
|
|
459
|
+
self._state[lane] = [
|
|
460
|
+
item for item in self._lane_items(lane) if item["id"] != message_id
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
def _check_rewind_gate(self, command: str) -> None:
|
|
464
|
+
status = self._status()
|
|
465
|
+
if status == "input-required":
|
|
466
|
+
raise _reject("wrong-state", f"{command} is rejected in input-required")
|
|
467
|
+
if status == "running":
|
|
468
|
+
if "rewind-during-run" not in self._capabilities:
|
|
469
|
+
raise _reject(
|
|
470
|
+
"capability-missing",
|
|
471
|
+
f"{command} during a run requires the rewind-during-run capability",
|
|
472
|
+
)
|
|
473
|
+
elif "rewind" not in self._capabilities:
|
|
474
|
+
raise _reject("capability-missing", "the rewind capability is not enabled")
|
|
475
|
+
|
|
476
|
+
def _check_leaf(self, meta: dict[str, Any], command: str) -> None:
|
|
477
|
+
if meta["isLeaf"]:
|
|
478
|
+
return
|
|
479
|
+
if self._status() == "running":
|
|
480
|
+
raise _reject("not-leaf", f"only the leaf accepts {command} during a run")
|
|
481
|
+
if self._lane_items("steerQueue") or self._lane_items("queue"):
|
|
482
|
+
raise _reject(
|
|
483
|
+
"not-leaf",
|
|
484
|
+
f"only the leaf accepts {command} while the queue is non-empty",
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def edit(self, params: Any) -> Any:
|
|
488
|
+
self._check_rewind_gate("run/edit")
|
|
489
|
+
source_id = params.get("sourceId") if isinstance(params, dict) else None
|
|
490
|
+
if not isinstance(source_id, str):
|
|
491
|
+
raise _reject("invalid-message", "sourceId must be a string")
|
|
492
|
+
meta = await self._get_message_meta(source_id)
|
|
493
|
+
if meta is None:
|
|
494
|
+
raise _reject("unknown-id", f"message {source_id} is unknown")
|
|
495
|
+
self._check_leaf(meta, "run/edit")
|
|
496
|
+
if meta["role"] != "user" and "assistant-edit" not in self._capabilities:
|
|
497
|
+
raise _reject(
|
|
498
|
+
"capability-missing", "the assistant-edit capability is not enabled"
|
|
499
|
+
)
|
|
500
|
+
message = self._validated_message(
|
|
501
|
+
params.get("message") if isinstance(params, dict) else None
|
|
502
|
+
)
|
|
503
|
+
if message["id"] != source_id and (
|
|
504
|
+
self._lane_of(message["id"]) is not None
|
|
505
|
+
or await self._get_message_meta(message["id"]) is not None
|
|
506
|
+
):
|
|
507
|
+
raise _reject("duplicate-id", f"message id {message['id']} is already used")
|
|
508
|
+
return self._rewind_dispatch("message-edit", [message], meta["parentId"])
|
|
509
|
+
|
|
510
|
+
async def reload(self, params: Any) -> Any:
|
|
511
|
+
self._check_rewind_gate("run/reload")
|
|
512
|
+
source_id = params.get("sourceId") if isinstance(params, dict) else None
|
|
513
|
+
if not isinstance(source_id, str):
|
|
514
|
+
raise _reject("invalid-message", "sourceId must be a string")
|
|
515
|
+
meta = await self._get_message_meta(source_id)
|
|
516
|
+
if meta is None:
|
|
517
|
+
raise _reject("unknown-id", f"message {source_id} is unknown")
|
|
518
|
+
if meta["role"] != "assistant":
|
|
519
|
+
raise _reject("invalid-message", "sourceId must name an assistant message")
|
|
520
|
+
self._check_leaf(meta, "run/reload")
|
|
521
|
+
if meta["parentId"] is not None:
|
|
522
|
+
parent = await self._get_message_meta(meta["parentId"])
|
|
523
|
+
if (
|
|
524
|
+
parent is not None
|
|
525
|
+
and parent["role"] == "assistant"
|
|
526
|
+
and "assistant-continuation" not in self._capabilities
|
|
527
|
+
):
|
|
528
|
+
raise _reject(
|
|
529
|
+
"capability-missing",
|
|
530
|
+
"the assistant-continuation capability is not enabled",
|
|
531
|
+
)
|
|
532
|
+
return self._rewind_dispatch("message-reload", [], meta["parentId"])
|
|
533
|
+
|
|
534
|
+
async def stop(self) -> Any:
|
|
535
|
+
status = self._status()
|
|
536
|
+
if status == "running":
|
|
537
|
+
task = self._task
|
|
538
|
+
ctx = self._ctx
|
|
539
|
+
assert task is not None and ctx is not None
|
|
540
|
+
ctx.stop_requested.set()
|
|
541
|
+
|
|
542
|
+
async def settle(ack: Callable[[], None]) -> None:
|
|
543
|
+
ack()
|
|
544
|
+
await task
|
|
545
|
+
|
|
546
|
+
return CommandExecution(settle)
|
|
547
|
+
if status == "input-required":
|
|
548
|
+
self._state["status"] = "stopped"
|
|
549
|
+
return None
|
|
550
|
+
raise _reject("wrong-state", f"run/stop is rejected in {status}")
|
|
551
|
+
|
|
552
|
+
async def continue_run(self) -> None:
|
|
553
|
+
status = self._status()
|
|
554
|
+
if status not in ("error", "stopped"):
|
|
555
|
+
raise _reject("wrong-state", f"run/continue is rejected in {status}")
|
|
556
|
+
if not self._lane_items("steerQueue") and (
|
|
557
|
+
"incomplete-continuation" not in self._capabilities
|
|
558
|
+
):
|
|
559
|
+
raise _reject(
|
|
560
|
+
"capability-missing",
|
|
561
|
+
"bare continue requires the incomplete-continuation capability",
|
|
562
|
+
)
|
|
563
|
+
self._dispatch(self._continue_type(), [])
|
|
564
|
+
|
|
565
|
+
# ─── Outcomes and context ───────────────────────────────
|
|
566
|
+
|
|
567
|
+
@dataclass(frozen=True)
|
|
568
|
+
class Complete:
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
@dataclass(frozen=True)
|
|
572
|
+
class InputRequired:
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
@dataclass(frozen=True, kw_only=True)
|
|
576
|
+
class Error:
|
|
577
|
+
dispatch_queue: bool
|
|
578
|
+
|
|
579
|
+
@dataclass(frozen=True, kw_only=True)
|
|
580
|
+
class Stop:
|
|
581
|
+
dispatch_queue: bool
|
|
582
|
+
|
|
583
|
+
@dataclass(frozen=True, eq=False)
|
|
584
|
+
class StartContext:
|
|
585
|
+
type: str
|
|
586
|
+
messages: tuple[dict[str, Any], ...]
|
|
587
|
+
caller: StatewireClientHandle | None
|
|
588
|
+
stop_requested: asyncio.Event
|
|
589
|
+
_manager: "RunManager"
|
|
590
|
+
_rollback_to: Any
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def has_rollback(self) -> bool:
|
|
594
|
+
return self._rollback_to is not _ABSENT
|
|
595
|
+
|
|
596
|
+
@property
|
|
597
|
+
def rollback_to(self) -> str | None:
|
|
598
|
+
if self._rollback_to is _ABSENT:
|
|
599
|
+
raise AttributeError(
|
|
600
|
+
"rollback_to is only present on rewind entries; check has_rollback"
|
|
601
|
+
)
|
|
602
|
+
return self._rollback_to
|
|
603
|
+
|
|
604
|
+
def _ensure_active(self) -> None:
|
|
605
|
+
if self._manager._ctx is not self:
|
|
606
|
+
raise RuntimeError("this run has already settled")
|
|
607
|
+
|
|
608
|
+
def has_steered(self) -> bool:
|
|
609
|
+
self._ensure_active()
|
|
610
|
+
return len(self._manager._lane_items("steerQueue")) > 0
|
|
611
|
+
|
|
612
|
+
def take_steered(self) -> tuple[dict[str, Any], ...]:
|
|
613
|
+
self._ensure_active()
|
|
614
|
+
items = self._manager._lane_items("steerQueue")
|
|
615
|
+
self._manager._state["steerQueue"] = []
|
|
616
|
+
for item in items:
|
|
617
|
+
self._manager._callers.pop(item["id"], None)
|
|
618
|
+
return tuple(
|
|
619
|
+
{k: v for k, v in item.items() if k != "caller"} for item in items
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def set_recovery_state(self, value: Any) -> None:
|
|
623
|
+
self._ensure_active()
|
|
624
|
+
record = self._manager._dispatch_record
|
|
625
|
+
assert record is not None
|
|
626
|
+
record["recoveryState"] = value
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: harness-sdk-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
|
|
5
|
+
Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: <4.0,>=3.11
|
|
8
|
+
Requires-Dist: statewire<0.3,>=0.2.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# harness-sdk-python
|
|
12
|
+
|
|
13
|
+
RunManager: the harness-sdk runs subsystem for Python Statewire hosts — the
|
|
14
|
+
`run/*` command protocol (queues, steering, edit/reload, stop/resume, crash
|
|
15
|
+
replay) driving a single async `start(ctx)` executor entrypoint.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
class MyHost(Statewire):
|
|
19
|
+
async def lifespan(self):
|
|
20
|
+
self.state = initial_state()
|
|
21
|
+
self.runs = RunManager(
|
|
22
|
+
state=self.state["runs"],
|
|
23
|
+
start=self._start,
|
|
24
|
+
get_message_meta=self._get_message_meta,
|
|
25
|
+
create_task=self.create_task,
|
|
26
|
+
capabilities=("rewind",),
|
|
27
|
+
)
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
@command("run/enqueue")
|
|
31
|
+
async def run_enqueue(self, params, *, caller):
|
|
32
|
+
return await self.runs.enqueue(params, caller=caller)
|
|
33
|
+
|
|
34
|
+
@command("run/steer")
|
|
35
|
+
async def run_steer(self, params, *, caller):
|
|
36
|
+
return await self.runs.steer(params, caller=caller)
|
|
37
|
+
|
|
38
|
+
@command("run/dequeue")
|
|
39
|
+
async def run_dequeue(self, params):
|
|
40
|
+
return await self.runs.dequeue(params)
|
|
41
|
+
|
|
42
|
+
@command("run/edit")
|
|
43
|
+
async def run_edit(self, params):
|
|
44
|
+
return await self.runs.edit(params)
|
|
45
|
+
|
|
46
|
+
@command("run/reload")
|
|
47
|
+
async def run_reload(self, params):
|
|
48
|
+
return await self.runs.reload(params)
|
|
49
|
+
|
|
50
|
+
@command("run/stop")
|
|
51
|
+
async def run_stop(self):
|
|
52
|
+
return await self.runs.stop()
|
|
53
|
+
|
|
54
|
+
@command("run/continue")
|
|
55
|
+
async def run_continue(self):
|
|
56
|
+
return await self.runs.continue_run()
|
|
57
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
harness_sdk/__init__.py,sha256=E4jrA6uGoPEPuCW0NGJKTAK6s8Ue5S8-b29ydtjYNSA,62
|
|
2
|
+
harness_sdk/run_manager.py,sha256=2Gdt_gW95mUajousNW0VZlmW-cXGTVzqZrhdegFsOyg,25325
|
|
3
|
+
harness_sdk_python-0.1.0.dist-info/METADATA,sha256=OW4L3fTDs0_AptpDarTDoBl1S9_xPKJl27lHJEcggTI,1770
|
|
4
|
+
harness_sdk_python-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
harness_sdk_python-0.1.0.dist-info/RECORD,,
|