harness-sdk-python 0.38.0__tar.gz → 0.39.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.
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/.gitignore +1 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/PKG-INFO +3 -3
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/pyproject.toml +2 -2
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents.py +75 -20
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents_host.py +15 -5
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/parts.py +13 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/run_manager.py +94 -35
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/voice.py +78 -11
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/voice_thread.py +11 -2
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/wire.py +5 -5
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_admit_steer.py +34 -3
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_branch_anchor.py +46 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_approval.py +99 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_files.py +123 -0
- harness_sdk_python-0.39.0/tests/test_deepagents_outcome.py +133 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_deepagents_thread.py +70 -3
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_deepagents_voice.py +10 -9
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_edit_reload.py +9 -7
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_enqueue.py +45 -0
- harness_sdk_python-0.39.0/tests/test_parts.py +96 -0
- harness_sdk_python-0.39.0/tests/test_run_id_bounds.py +267 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_steer.py +44 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_voice.py +71 -30
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_voice_delegation.py +22 -7
- harness_sdk_python-0.39.0/tests/test_voice_events.py +175 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_wire.py +25 -1
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/voice_helpers.py +4 -0
- harness_sdk_python-0.38.0/tests/test_parts.py +0 -49
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/README.md +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/interest.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/langgraph.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/messages.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/protocol.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/providers/__init__.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/providers/gpt_live.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/threads.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_gpt_live.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_imports.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_interest.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_langgraph.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_admission.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_sync.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_threads.py +0 -0
- {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_undo.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.39.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
|
-
Requires-Dist: pinned<0.
|
|
9
|
-
Requires-Dist: statewire<0.
|
|
8
|
+
Requires-Dist: pinned<0.17,>=0.16.0
|
|
9
|
+
Requires-Dist: statewire<0.23,>=0.21.1
|
|
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.
|
|
3
|
+
version = "0.39.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.
|
|
8
|
+
dependencies = ["statewire>=0.21.1,<0.23", "pinned>=0.16.0,<0.17"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -10,6 +10,7 @@ main run; its rows persist under ``checkpoint_ns == ns``.
|
|
|
10
10
|
from typing import Any, Callable
|
|
11
11
|
|
|
12
12
|
from statepatch import plain
|
|
13
|
+
from statewire import StatewireReject
|
|
13
14
|
|
|
14
15
|
from .checkpoints import (
|
|
15
16
|
CheckpointIndex,
|
|
@@ -32,8 +33,45 @@ def deepagents_role(message: dict[str, Any]) -> str:
|
|
|
32
33
|
raise ValueError(f"unsupported message type: {message['type']!r}") from exc
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
-
|
|
36
|
+
def _file_block(part: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
url, media_type = part["url"], part["mediaType"]
|
|
38
|
+
if url.startswith("data:"):
|
|
39
|
+
header, comma, data = url.removeprefix("data:").partition(",")
|
|
40
|
+
media_type, *params = header.split(";")
|
|
41
|
+
if not comma or not media_type or params[-1:] != ["base64"]:
|
|
42
|
+
raise ValueError("file data URLs must be data:<type>;base64,<data>")
|
|
43
|
+
source = {"base64": data, "mime_type": media_type}
|
|
44
|
+
else:
|
|
45
|
+
raise ValueError("file URLs must be inline data:<type>;base64,<data> URLs")
|
|
46
|
+
kind = media_type.split("/", 1)[0]
|
|
47
|
+
block = {"type": kind if kind in ("image", "audio", "video") else "file", **source}
|
|
48
|
+
if "filename" in part:
|
|
49
|
+
block["extras"] = {"filename": part["filename"]}
|
|
50
|
+
return block
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _user_content(message: dict[str, Any]) -> str | list[dict[str, Any]]:
|
|
54
|
+
parts = message["parts"]
|
|
55
|
+
if all(part["type"] == "text" for part in parts):
|
|
56
|
+
return "\n\n".join(part["text"] for part in parts)
|
|
57
|
+
return [{"type": "text", "text": part["text"]} if part["type"] == "text" else _file_block(part) for part in parts]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_file_parts(message: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
"""``prepare_message`` hook: a file part ``_file_block`` cannot carry rejects ``invalid-message``."""
|
|
62
|
+
parts = message.get("parts")
|
|
63
|
+
for part in parts if isinstance(parts, list) else ():
|
|
64
|
+
if (
|
|
65
|
+
isinstance(part, dict)
|
|
66
|
+
and part.get("type") == "file"
|
|
67
|
+
and isinstance(part.get("url"), str)
|
|
68
|
+
and isinstance(part.get("mediaType"), str)
|
|
69
|
+
):
|
|
70
|
+
try:
|
|
71
|
+
_file_block(part)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise StatewireReject(str(exc), payload={"reason": "invalid-message"}) from exc
|
|
74
|
+
return message
|
|
37
75
|
|
|
38
76
|
|
|
39
77
|
def _ns_of(namespace: tuple[str, ...]) -> str:
|
|
@@ -123,19 +161,25 @@ class DeepagentsThread:
|
|
|
123
161
|
"""The dispatch's langgraph (payload, config): forks at the anchor when the batch does not extend the head."""
|
|
124
162
|
self.abandon_turn()
|
|
125
163
|
config = self.config()
|
|
126
|
-
if ctx.resume:
|
|
127
|
-
return None, config
|
|
128
164
|
thread = self.tree.thread(MAIN_NS)
|
|
129
165
|
if ctx.trigger == "input-resume":
|
|
130
166
|
from langgraph.types import Command
|
|
131
167
|
|
|
168
|
+
tool_ids = {p.get("toolInvocationId") for p in plain(self.tree.get(MAIN_NS, thread.head)["parts"])}
|
|
169
|
+
missing = [r["toolCallId"] for r, _response, _meta in ctx.input_outcomes if r["toolCallId"] not in tool_ids]
|
|
170
|
+
if missing:
|
|
171
|
+
raise KeyError(f"head {thread.head!r} holds no tool part for {missing!r}")
|
|
172
|
+
self._mount(MAIN_NS, thread.head)
|
|
173
|
+
if ctx.resume:
|
|
174
|
+
return None, config
|
|
132
175
|
decisions = [
|
|
133
176
|
{"type": "approve"} if response and response.get("decision") == "approve" else {"type": "reject"}
|
|
134
177
|
for _request, response, _meta in ctx.input_outcomes
|
|
135
178
|
]
|
|
136
179
|
self._admission = ctx.applied()
|
|
137
|
-
self._assemblers.pop(MAIN_NS, None)
|
|
138
180
|
return Command(resume={"decisions": decisions}), config
|
|
181
|
+
if ctx.resume:
|
|
182
|
+
return None, config
|
|
139
183
|
if ctx.has_rollback:
|
|
140
184
|
parent = ctx.rollback_to
|
|
141
185
|
elif ctx.messages:
|
|
@@ -156,7 +200,7 @@ class DeepagentsThread:
|
|
|
156
200
|
|
|
157
201
|
turn: list[Any] = [*plan.replay]
|
|
158
202
|
for message in ctx.messages:
|
|
159
|
-
turn.append(HumanMessage(id=message["id"], content=
|
|
203
|
+
turn.append(HumanMessage(id=message["id"], content=_user_content(message)))
|
|
160
204
|
self._append_user(message, parent)
|
|
161
205
|
parent = message["id"]
|
|
162
206
|
self.tree.set_head(MAIN_NS, parent)
|
|
@@ -196,7 +240,7 @@ class DeepagentsThread:
|
|
|
196
240
|
turn = []
|
|
197
241
|
for message in messages:
|
|
198
242
|
self._append_user(message, thread.head)
|
|
199
|
-
turn.append(HumanMessage(id=message["id"], content=
|
|
243
|
+
turn.append(HumanMessage(id=message["id"], content=_user_content(message)))
|
|
200
244
|
self._thread_state(MAIN_NS)
|
|
201
245
|
self._assemblers.pop(MAIN_NS, None)
|
|
202
246
|
self._changed()
|
|
@@ -233,10 +277,14 @@ class DeepagentsThread:
|
|
|
233
277
|
"metadata": {"provider": {"ids": []}},
|
|
234
278
|
}
|
|
235
279
|
self.tree.add(ns, value)
|
|
236
|
-
self.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
280
|
+
assembler = self._mount(ns, message_id)
|
|
281
|
+
return assembler
|
|
282
|
+
|
|
283
|
+
def _mount(self, ns: str, message_id: str) -> PartsAssembler:
|
|
284
|
+
self._touched.add((ns, message_id))
|
|
285
|
+
assembler = self._assemblers[ns] = PartsAssembler(lambda: self.tree.get(ns, message_id))
|
|
286
|
+
self._thread_state(ns, status="streaming")
|
|
287
|
+
self._changed()
|
|
240
288
|
return assembler
|
|
241
289
|
|
|
242
290
|
def _fold_id(self, assembler: PartsAssembler, langchain_id: str | None) -> None:
|
|
@@ -317,14 +365,16 @@ class DeepagentsThread:
|
|
|
317
365
|
self.interrupt = {"toolCallId": tool_id}
|
|
318
366
|
self._changed()
|
|
319
367
|
|
|
320
|
-
def _finish(self, ns: str) -> None:
|
|
368
|
+
def _finish(self, ns: str, outcome: dict[str, Any] | None = None) -> None:
|
|
321
369
|
assembler = self._assemblers.pop(ns, None)
|
|
322
370
|
if assembler is not None:
|
|
323
371
|
assembler.finish()
|
|
372
|
+
for key, value in (outcome or {}).items():
|
|
373
|
+
assembler.message[key] = value
|
|
324
374
|
self._thread_state(ns, status="idle")
|
|
325
375
|
|
|
326
|
-
async def ingest_turn(self, *, refetch: bool = False) -> None:
|
|
327
|
-
"""Ingest the turn's captured root checkpoint, persist the touched harness messages, and settle streaming state."""
|
|
376
|
+
async def ingest_turn(self, *, refetch: bool = False, outcome: dict[str, Any] | None = None) -> None:
|
|
377
|
+
"""Ingest the turn's captured root checkpoint, persist the touched harness messages, and settle streaming state; ``outcome`` lands on the turn's open replies."""
|
|
328
378
|
captured, self._captured = self._captured, None
|
|
329
379
|
if refetch:
|
|
330
380
|
await self._thread.ingest_latest()
|
|
@@ -336,7 +386,7 @@ class DeepagentsThread:
|
|
|
336
386
|
self._confirm_admission()
|
|
337
387
|
for ns in self._turn_namespaces():
|
|
338
388
|
if self.interrupt is None or ns != MAIN_NS:
|
|
339
|
-
self._finish(ns)
|
|
389
|
+
self._finish(ns, outcome)
|
|
340
390
|
for ns in self.tree.threads:
|
|
341
391
|
if ns != MAIN_NS and _root_of(ns) not in self._subagents:
|
|
342
392
|
self._thread_state(ns, status="idle")
|
|
@@ -354,14 +404,14 @@ class DeepagentsThread:
|
|
|
354
404
|
raise ValueError(f"subagent namespace must be a root namespace other than {MAIN_NS!r}, got {ns!r}")
|
|
355
405
|
if ns in self._subagents:
|
|
356
406
|
raise RuntimeError(f"subagent {ns!r} is already running")
|
|
407
|
+
config = {"configurable": {"thread_id": f"{self._thread_id}/{ns}"}}
|
|
408
|
+
payload = {"messages": [HumanMessage(id=message["id"], content=_user_content(message))]}
|
|
357
409
|
thread = self.tree.thread(ns)
|
|
358
410
|
self.tree.add(ns, {**message, "parentId": thread.head})
|
|
359
411
|
self._touched.add((ns, message["id"]))
|
|
360
412
|
self._thread_state(ns, status="streaming")
|
|
361
413
|
self._subagents.add(ns)
|
|
362
414
|
self._changed()
|
|
363
|
-
config = {"configurable": {"thread_id": f"{self._thread_id}/{ns}"}}
|
|
364
|
-
payload = {"messages": [HumanMessage(id=message["id"], content=_user_text(message))]}
|
|
365
415
|
try:
|
|
366
416
|
async for namespace, mode, event in self._agent.astream(
|
|
367
417
|
payload, config=config, stream_mode=["messages", "updates", "checkpoints"], subgraphs=True
|
|
@@ -416,14 +466,19 @@ class DeepagentsThread:
|
|
|
416
466
|
await self._persist()
|
|
417
467
|
self._changed()
|
|
418
468
|
|
|
419
|
-
def
|
|
420
|
-
"""
|
|
469
|
+
async def fail_turn(self, error: str) -> None:
|
|
470
|
+
"""Abandon the turn with its open replies marked ``failed`` and persist the touched messages."""
|
|
471
|
+
self.abandon_turn({"status": "failed", "error": error})
|
|
472
|
+
await self._persist()
|
|
473
|
+
|
|
474
|
+
def abandon_turn(self, outcome: dict[str, Any] | None = None) -> None:
|
|
475
|
+
"""Discard a stream capture and any fork planned for the abandoned turn; ``outcome`` lands on the turn's open replies."""
|
|
421
476
|
self._captured = None
|
|
422
477
|
self._admission = None
|
|
423
478
|
self._input_checkpoint_seen = False
|
|
424
479
|
self.interrupt = None
|
|
425
480
|
for ns in self._turn_namespaces():
|
|
426
|
-
self._finish(ns)
|
|
481
|
+
self._finish(ns, outcome)
|
|
427
482
|
self._thread.abandon_plan()
|
|
428
483
|
|
|
429
484
|
|
|
@@ -14,10 +14,10 @@ from typing import Any
|
|
|
14
14
|
from statewire import Statewire, command
|
|
15
15
|
|
|
16
16
|
from .checkpoints import CheckpointIndex
|
|
17
|
-
from .deepagents import DeepagentsThread
|
|
17
|
+
from .deepagents import DeepagentsThread, check_file_parts
|
|
18
18
|
from .messages import MAIN_NS
|
|
19
19
|
from .protocol import HARNESS_PROTOCOL
|
|
20
|
-
from .run_manager import RunManager, run_manager
|
|
20
|
+
from .run_manager import RunManager, error_of, run_manager
|
|
21
21
|
from .voice import VoiceTurnCommitDeferred, end_voice, set_voice_mic, start_voice, voice_client_departed
|
|
22
22
|
from .voice_thread import VoiceThread, is_ephemeral
|
|
23
23
|
from .wire import HarnessMessages
|
|
@@ -26,6 +26,11 @@ logger = logging.getLogger(__name__)
|
|
|
26
26
|
_HALTED = ("stopped", "error")
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def _interrupted(ctx: RunManager.RunContext) -> dict[str, Any]:
|
|
30
|
+
reason = ctx.stop_reason
|
|
31
|
+
return {"status": "interrupted"} if reason is None else {"status": "interrupted", "stopReason": reason}
|
|
32
|
+
|
|
33
|
+
|
|
29
34
|
class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
|
|
30
35
|
"""One thread: ``build_agent(checkpointer)`` returns the compiled deepagents graph."""
|
|
31
36
|
|
|
@@ -128,15 +133,20 @@ class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
|
|
|
128
133
|
|
|
129
134
|
# ─── Runs ───────────────────────────────────────────────
|
|
130
135
|
|
|
131
|
-
@run_manager(capabilities=("rewind",))
|
|
136
|
+
@run_manager(capabilities=("rewind", "files"), prepare_message=check_file_parts)
|
|
132
137
|
async def run(self, ctx: RunManager.RunContext) -> Any:
|
|
133
138
|
payload, config = await self.messages.plan_turn(ctx)
|
|
134
139
|
while True:
|
|
135
140
|
try:
|
|
136
141
|
outcome = await self._stream_turn(ctx, payload, config)
|
|
137
|
-
await self.messages.ingest_turn(
|
|
142
|
+
await self.messages.ingest_turn(
|
|
143
|
+
refetch=outcome is not None, outcome=None if outcome is None else _interrupted(ctx)
|
|
144
|
+
)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
await self.messages.fail_turn(error_of(exc)["message"])
|
|
147
|
+
raise
|
|
138
148
|
except BaseException:
|
|
139
|
-
self.messages.abandon_turn()
|
|
149
|
+
self.messages.abandon_turn(_interrupted(ctx))
|
|
140
150
|
raise
|
|
141
151
|
if outcome is not None:
|
|
142
152
|
return outcome
|
|
@@ -73,6 +73,7 @@ class PartsAssembler:
|
|
|
73
73
|
text = text_of(content)
|
|
74
74
|
if text:
|
|
75
75
|
self._append_text("text", text, chunk_id)
|
|
76
|
+
self._cite(chunk)
|
|
76
77
|
for tool_chunk in getattr(chunk, "tool_call_chunks", None) or []:
|
|
77
78
|
key = (chunk_id, tool_chunk.get("index") or 0)
|
|
78
79
|
tool_id = tool_chunk.get("id") or self._chunk_tools.get(key)
|
|
@@ -120,6 +121,18 @@ class PartsAssembler:
|
|
|
120
121
|
elif call.get("args"):
|
|
121
122
|
self.parts[index]["input"] = call["args"]
|
|
122
123
|
|
|
124
|
+
def _cite(self, chunk: Any) -> None:
|
|
125
|
+
cited = {part["url"] for part in plain(self.parts) if part["type"] == "source"}
|
|
126
|
+
for block in chunk.content_blocks:
|
|
127
|
+
if block["type"] != "text":
|
|
128
|
+
continue
|
|
129
|
+
for note in block.get("annotations", []):
|
|
130
|
+
url = note.get("url") if note["type"] == "citation" else None
|
|
131
|
+
if url and url not in cited:
|
|
132
|
+
cited.add(url)
|
|
133
|
+
title = note.get("title")
|
|
134
|
+
self.parts.append({"type": "source", "url": url, **({"title": title} if title else {})})
|
|
135
|
+
|
|
123
136
|
def tool_result(self, message: Any) -> None:
|
|
124
137
|
tool_id = getattr(message, "tool_call_id", None)
|
|
125
138
|
index = self._index(tool_id) if tool_id else None
|
|
@@ -79,9 +79,14 @@ from pinned.trays import Tray, TrayDescriptor, TrayCtx, TrayMapNamespace, inbox
|
|
|
79
79
|
from pinned.trays import persistent, persistent_outbox, persistent_queue
|
|
80
80
|
from statepatch import observable
|
|
81
81
|
from statewire import StatewireReject
|
|
82
|
+
from statewire.ops import encode_envelope
|
|
82
83
|
|
|
83
84
|
logger = logging.getLogger(__name__)
|
|
84
85
|
|
|
86
|
+
MAX_MESSAGE_ID_LENGTH = 256
|
|
87
|
+
MAX_MESSAGE_BYTES = 1 << 20
|
|
88
|
+
MAX_RUN_IDS = 4096
|
|
89
|
+
|
|
85
90
|
_ABSENT: Any = object()
|
|
86
91
|
_PARKED: Any = object()
|
|
87
92
|
|
|
@@ -110,16 +115,27 @@ _TRIGGERS = (
|
|
|
110
115
|
|
|
111
116
|
_DECISIONS = ("approve", "reject", "edit", "respond")
|
|
112
117
|
|
|
113
|
-
_RESERVED_KEYS = frozenset({"~admit"})
|
|
118
|
+
_RESERVED_KEYS = frozenset({"~admit", "~parent"})
|
|
114
119
|
|
|
115
120
|
|
|
116
121
|
def _reject(reason: str, message: str) -> StatewireReject:
|
|
117
122
|
return StatewireReject(message, payload={"reason": reason})
|
|
118
123
|
|
|
119
124
|
|
|
125
|
+
def error_of(exc: Exception) -> dict[str, Any]:
|
|
126
|
+
"""A run's client-visible error: a reject's message and payload, any other exception's type name only."""
|
|
127
|
+
if isinstance(exc, StatewireReject):
|
|
128
|
+
if isinstance(exc.payload, dict):
|
|
129
|
+
return {**exc.payload, "message": exc.message}
|
|
130
|
+
if exc.payload is not None:
|
|
131
|
+
return {"message": exc.message, "payload": exc.payload}
|
|
132
|
+
return {"message": exc.message}
|
|
133
|
+
return {"message": type(exc).__name__}
|
|
134
|
+
|
|
135
|
+
|
|
120
136
|
def _public(message: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
-
"""The message without its
|
|
122
|
-
return {k: v for k, v in message.items() if k
|
|
137
|
+
"""The message without its reserved tags; the wire state is public."""
|
|
138
|
+
return {k: v for k, v in message.items() if k not in _RESERVED_KEYS}
|
|
123
139
|
|
|
124
140
|
|
|
125
141
|
def _public_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -158,6 +174,12 @@ def _validated_message(cls: "type[RunManager]", message: Any) -> dict[str, Any]:
|
|
|
158
174
|
)
|
|
159
175
|
if not isinstance(message.get("id"), str) or message["id"] == "":
|
|
160
176
|
raise _reject("invalid-message", "message.id must be a non-empty string")
|
|
177
|
+
if len(message["id"]) > MAX_MESSAGE_ID_LENGTH:
|
|
178
|
+
raise _reject(
|
|
179
|
+
"invalid-message", f"message.id exceeds {MAX_MESSAGE_ID_LENGTH} characters"
|
|
180
|
+
)
|
|
181
|
+
if message["id"].startswith("voice-"):
|
|
182
|
+
raise _reject("invalid-message", "message.id uses the reserved voice- prefix")
|
|
161
183
|
if message.get("role") != "user":
|
|
162
184
|
raise _reject("invalid-message", 'message.role must be "user"')
|
|
163
185
|
parts = message.get("parts")
|
|
@@ -198,9 +220,18 @@ def _validated_message(cls: "type[RunManager]", message: Any) -> dict[str, Any]:
|
|
|
198
220
|
metadata = message.get("metadata", _ABSENT)
|
|
199
221
|
if metadata is not _ABSENT and not isinstance(metadata, dict):
|
|
200
222
|
raise _reject("invalid-message", "message.metadata must be an object")
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
223
|
+
if "parentId" in message:
|
|
224
|
+
raise _reject(
|
|
225
|
+
"invalid-message", "message.parentId is not a send field: runAnchorMessageId names the parent"
|
|
226
|
+
)
|
|
227
|
+
try:
|
|
228
|
+
encoded = encode_envelope(message)
|
|
229
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
230
|
+
raise _reject("invalid-message", "message is not wire-encodable") from exc
|
|
231
|
+
if len(encoded) > MAX_MESSAGE_BYTES:
|
|
232
|
+
raise _reject(
|
|
233
|
+
"invalid-message", f"message exceeds {MAX_MESSAGE_BYTES} encoded bytes"
|
|
234
|
+
)
|
|
204
235
|
return message
|
|
205
236
|
|
|
206
237
|
|
|
@@ -291,7 +322,6 @@ class _Dequeue:
|
|
|
291
322
|
|
|
292
323
|
@dataclass
|
|
293
324
|
class _Edit:
|
|
294
|
-
source_id: str
|
|
295
325
|
source_meta: dict[str, Any]
|
|
296
326
|
message: dict[str, Any]
|
|
297
327
|
meta: Any
|
|
@@ -602,7 +632,32 @@ class RunManager(Tray):
|
|
|
602
632
|
|
|
603
633
|
# ─── Staging and advance ────────────────────────────────
|
|
604
634
|
|
|
635
|
+
def _tracked_ids(self) -> set[str]:
|
|
636
|
+
ids = set(self.dispatchedIds.value) | set(self.authors.value)
|
|
637
|
+
for lane in ("queue", "steerQueue"):
|
|
638
|
+
ids.update(item["id"] for item in self._lane_items(lane))
|
|
639
|
+
ids.update(item["id"] for _, item in self._batch)
|
|
640
|
+
if self._staged_rewind is not None:
|
|
641
|
+
ids.update(item["id"] for item in self._staged_rewind.messages)
|
|
642
|
+
for entry in self._intake:
|
|
643
|
+
if isinstance(entry, (_Send, _Edit)) and entry.message is not None:
|
|
644
|
+
ids.add(entry.message["id"])
|
|
645
|
+
return ids
|
|
646
|
+
|
|
647
|
+
def _check_id_capacity(self, ids: Iterable[str]) -> None:
|
|
648
|
+
known = self._tracked_ids()
|
|
649
|
+
added = set(ids) - known
|
|
650
|
+
if added and len(known) + len(added) > MAX_RUN_IDS:
|
|
651
|
+
raise _reject("run-full", f"run id limit reached ({MAX_RUN_IDS} unique ids)")
|
|
652
|
+
|
|
605
653
|
def _admit(self, entry: Any) -> None:
|
|
654
|
+
if isinstance(entry, (_Send, _Edit)) and entry.message is not None:
|
|
655
|
+
try:
|
|
656
|
+
self._check_id_capacity((entry.message["id"],))
|
|
657
|
+
except StatewireReject as exc:
|
|
658
|
+
if not entry.future.done():
|
|
659
|
+
entry.future.set_exception(exc)
|
|
660
|
+
return
|
|
606
661
|
self._idle.clear()
|
|
607
662
|
self._intake.append(entry)
|
|
608
663
|
self._schedule(self._advance)
|
|
@@ -810,6 +865,7 @@ class RunManager(Tray):
|
|
|
810
865
|
raise ValueError(f"invalid trigger: {trigger!r}")
|
|
811
866
|
if self._in_flight():
|
|
812
867
|
raise RuntimeError("a run is already in flight")
|
|
868
|
+
self._check_id_capacity(message["id"] for message in messages)
|
|
813
869
|
record = self._record_of(
|
|
814
870
|
trigger,
|
|
815
871
|
list(messages),
|
|
@@ -838,7 +894,7 @@ class RunManager(Tray):
|
|
|
838
894
|
]
|
|
839
895
|
if stripped:
|
|
840
896
|
known = self.dispatchedIds.value
|
|
841
|
-
added =
|
|
897
|
+
added = list(dict.fromkeys(m["id"] for m in stripped if m["id"] not in known))
|
|
842
898
|
if added:
|
|
843
899
|
self.dispatchedIds.value = [*known, *added]
|
|
844
900
|
self.dispatching.value = _public_record(record)
|
|
@@ -876,7 +932,11 @@ class RunManager(Tray):
|
|
|
876
932
|
elif record.get("inputOutcomes"):
|
|
877
933
|
root_meta = record["inputOutcomes"][0]["meta"]
|
|
878
934
|
messages = [
|
|
879
|
-
{
|
|
935
|
+
{
|
|
936
|
+
"parentId" if k == "~parent" else k: v
|
|
937
|
+
for k, v in message.items()
|
|
938
|
+
if k not in ("meta", "~admit")
|
|
939
|
+
}
|
|
880
940
|
for message in record["messages"]
|
|
881
941
|
]
|
|
882
942
|
ctx = RunManager.RunContext(
|
|
@@ -975,7 +1035,7 @@ class RunManager(Tray):
|
|
|
975
1035
|
self.id,
|
|
976
1036
|
getattr(fn, "__qualname__", repr(fn)),
|
|
977
1037
|
)
|
|
978
|
-
error =
|
|
1038
|
+
error = error_of(exc)
|
|
979
1039
|
self.outcome.value = {
|
|
980
1040
|
"kind": "error",
|
|
981
1041
|
"epoch": self.epoch.value,
|
|
@@ -1036,15 +1096,6 @@ class RunManager(Tray):
|
|
|
1036
1096
|
return
|
|
1037
1097
|
self.delete()
|
|
1038
1098
|
|
|
1039
|
-
def _error_of(self, exc: Exception) -> dict[str, Any]:
|
|
1040
|
-
if isinstance(exc, StatewireReject):
|
|
1041
|
-
if isinstance(exc.payload, dict):
|
|
1042
|
-
return {**exc.payload, "message": exc.message}
|
|
1043
|
-
if exc.payload is not None:
|
|
1044
|
-
return {"message": exc.message, "payload": exc.payload}
|
|
1045
|
-
return {"message": exc.message}
|
|
1046
|
-
return {"message": type(exc).__name__}
|
|
1047
|
-
|
|
1048
1099
|
def _outcome_record(self, outcome: Any) -> dict[str, Any]:
|
|
1049
1100
|
epoch = self.epoch.value
|
|
1050
1101
|
if isinstance(outcome, RunManager.Complete):
|
|
@@ -1162,7 +1213,7 @@ class RunManager(Tray):
|
|
|
1162
1213
|
if queue.value and queue.peek()["id"] == item["id"]:
|
|
1163
1214
|
queue.popleft()
|
|
1164
1215
|
for lane, item in reversed(taken):
|
|
1165
|
-
self._lane(lane).insert(0, item)
|
|
1216
|
+
self._lane(lane).insert(0, {k: v for k, v in item.items() if k != "~parent"})
|
|
1166
1217
|
|
|
1167
1218
|
def _dispatchable(self, lane: str) -> bool:
|
|
1168
1219
|
# an unconsumed outcome means the settle is mid-drain: its follow-on decides
|
|
@@ -1200,8 +1251,11 @@ class RunManager(Tray):
|
|
|
1200
1251
|
)
|
|
1201
1252
|
if len(self._batch) + len(steer) >= self.max_queued:
|
|
1202
1253
|
raise _reject("queue-full", f"queue is full ({self.max_queued} messages)")
|
|
1254
|
+
staged = self._stamped(e.message, e.meta)
|
|
1255
|
+
if not self._batch and not steer and e.anchor is not _ABSENT:
|
|
1256
|
+
staged["~parent"] = e.anchor
|
|
1203
1257
|
self._batch.extend(("steerQueue", item) for item in steer)
|
|
1204
|
-
self._batch.append((e.lane,
|
|
1258
|
+
self._batch.append((e.lane, staged))
|
|
1205
1259
|
self.dispatching.value = _public_record(
|
|
1206
1260
|
self._record_of(self._send_trigger(), [item for _, item in self._batch])
|
|
1207
1261
|
)
|
|
@@ -1403,14 +1457,15 @@ class RunManager(Tray):
|
|
|
1403
1457
|
self._check_anchor(e)
|
|
1404
1458
|
# ids stay bound to their first author: a dequeued id re-enters authors-only
|
|
1405
1459
|
self._check_author(e.message_id, e.principal)
|
|
1406
|
-
|
|
1407
|
-
self.authors.value = {**self.authors.value, e.message_id: e.principal}
|
|
1460
|
+
self._check_id_capacity((e.message_id,))
|
|
1408
1461
|
if self._dispatchable(e.lane):
|
|
1409
1462
|
self._stage_batch(e)
|
|
1410
1463
|
else:
|
|
1411
1464
|
self._insert_new(e.lane, e.message, e.params, e.meta)
|
|
1412
1465
|
if e.lane == "steerQueue":
|
|
1413
1466
|
fx.steer_added = True
|
|
1467
|
+
if e.principal is not None:
|
|
1468
|
+
self.authors.value = {**self.authors.value, e.message_id: e.principal}
|
|
1414
1469
|
# A mint racing a live run merges into it; settle carries the surviving id.
|
|
1415
1470
|
result = None if e.minted or e.anchor is _ABSENT else {"runId": self.id}
|
|
1416
1471
|
fx.staged_sends.append((e.future, e.applied, result))
|
|
@@ -1498,14 +1553,12 @@ class RunManager(Tray):
|
|
|
1498
1553
|
|
|
1499
1554
|
def _apply_edit(self, e: _Edit) -> Any:
|
|
1500
1555
|
self._check_run_owner(e.principal)
|
|
1556
|
+
self._check_id_capacity((e.message["id"],))
|
|
1501
1557
|
staged = self._staged_rewind
|
|
1502
1558
|
if (
|
|
1503
1559
|
staged is not None
|
|
1504
1560
|
and any(m["id"] == e.message["id"] for m in staged.messages)
|
|
1505
|
-
) or (
|
|
1506
|
-
e.message["id"] != e.source_id
|
|
1507
|
-
and self._lane_of(e.message["id"]) is not None
|
|
1508
|
-
):
|
|
1561
|
+
) or self._lane_of(e.message["id"]) is not None:
|
|
1509
1562
|
raise _reject(
|
|
1510
1563
|
"duplicate-id", f"message id {e.message['id']} is already used"
|
|
1511
1564
|
)
|
|
@@ -1541,8 +1594,17 @@ class RunManager(Tray):
|
|
|
1541
1594
|
@inbox
|
|
1542
1595
|
def admit_steer(self, message: dict[str, Any]) -> None:
|
|
1543
1596
|
"""Durably admit one steer message; a ``ctx.dispatch`` target."""
|
|
1544
|
-
|
|
1545
|
-
|
|
1597
|
+
message = _validated_message(type(self), message)
|
|
1598
|
+
admission = self.storage._admission
|
|
1599
|
+
replay = admission is not None and (
|
|
1600
|
+
admission.ignored or any(
|
|
1601
|
+
item.get("~admit") == admission.key
|
|
1602
|
+
for item in [*self.steerQueue._taken, *self.steerQueue.value]
|
|
1603
|
+
)
|
|
1604
|
+
)
|
|
1605
|
+
if not replay:
|
|
1606
|
+
self._check_id_capacity((message["id"],))
|
|
1607
|
+
self._check_capacity("steerQueue")
|
|
1546
1608
|
self._idle.clear()
|
|
1547
1609
|
self.steerQueue.append(self._stamped(message, message.get("meta")))
|
|
1548
1610
|
self._sync_steering()
|
|
@@ -1653,13 +1715,14 @@ class RunManager(Tray):
|
|
|
1653
1715
|
raise RuntimeError("take() after undo()")
|
|
1654
1716
|
if manager.dispatching.value is not None:
|
|
1655
1717
|
raise RuntimeError("take before the current batch is applied")
|
|
1718
|
+
manager._check_id_capacity(item["id"] for item in manager.steerQueue.value)
|
|
1656
1719
|
items = manager.steerQueue.take_now()
|
|
1657
1720
|
if not items:
|
|
1658
1721
|
return ()
|
|
1659
1722
|
manager._pending_takes += len(items)
|
|
1660
1723
|
manager._batch = [("steerQueue", item) for item in items]
|
|
1661
1724
|
known = manager.dispatchedIds.value
|
|
1662
|
-
added =
|
|
1725
|
+
added = list(dict.fromkeys(item["id"] for item in items if item["id"] not in known))
|
|
1663
1726
|
if added:
|
|
1664
1727
|
manager.dispatchedIds.value = [*known, *added]
|
|
1665
1728
|
manager.dispatching.value = _public_record(
|
|
@@ -2172,15 +2235,11 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
2172
2235
|
message = _validated_message(
|
|
2173
2236
|
cls, params.get("message") if isinstance(params, dict) else None
|
|
2174
2237
|
)
|
|
2175
|
-
if (
|
|
2176
|
-
message["id"] != source_id
|
|
2177
|
-
and await self._api.get_message_meta(message["id"]) is not None
|
|
2178
|
-
):
|
|
2238
|
+
if await self._api.get_message_meta(message["id"]) is not None:
|
|
2179
2239
|
raise _reject("duplicate-id", f"message id {message['id']} is already used")
|
|
2180
2240
|
tray, _minted = await self._machine(run_id, auth_result)
|
|
2181
2241
|
return await tray._stage(
|
|
2182
2242
|
_Edit(
|
|
2183
|
-
source_id,
|
|
2184
2243
|
source_meta,
|
|
2185
2244
|
message,
|
|
2186
2245
|
meta,
|