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.
Files changed (73) hide show
  1. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/.gitignore +1 -0
  2. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/PKG-INFO +3 -3
  3. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/pyproject.toml +2 -2
  4. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents.py +75 -20
  5. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/deepagents_host.py +15 -5
  6. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/parts.py +13 -0
  7. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/run_manager.py +94 -35
  8. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/voice.py +78 -11
  9. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/voice_thread.py +11 -2
  10. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/wire.py +5 -5
  11. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_admit_steer.py +34 -3
  12. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_branch_anchor.py +46 -0
  13. harness_sdk_python-0.39.0/tests/test_deepagents_approval.py +99 -0
  14. harness_sdk_python-0.39.0/tests/test_deepagents_files.py +123 -0
  15. harness_sdk_python-0.39.0/tests/test_deepagents_outcome.py +133 -0
  16. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_deepagents_thread.py +70 -3
  17. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_deepagents_voice.py +10 -9
  18. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_edit_reload.py +9 -7
  19. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_enqueue.py +45 -0
  20. harness_sdk_python-0.39.0/tests/test_parts.py +96 -0
  21. harness_sdk_python-0.39.0/tests/test_run_id_bounds.py +267 -0
  22. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_steer.py +44 -0
  23. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_voice.py +71 -30
  24. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_voice_delegation.py +22 -7
  25. harness_sdk_python-0.39.0/tests/test_voice_events.py +175 -0
  26. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_wire.py +25 -1
  27. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/voice_helpers.py +4 -0
  28. harness_sdk_python-0.38.0/tests/test_parts.py +0 -49
  29. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/README.md +0 -0
  30. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/examples/__init__.py +0 -0
  31. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/examples/runs_app.py +0 -0
  32. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/__init__.py +0 -0
  33. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/checkpoints.py +0 -0
  34. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/fenced_postgres.py +0 -0
  35. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/interest.py +0 -0
  36. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/langgraph.py +0 -0
  37. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/linear_thread.py +0 -0
  38. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/messages.py +0 -0
  39. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/protocol.py +0 -0
  40. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/providers/__init__.py +0 -0
  41. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/providers/gpt_live.py +0 -0
  42. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/src/harness_sdk/threads.py +0 -0
  43. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/run_helpers.py +0 -0
  44. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_applied_visibility.py +0 -0
  45. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_batches.py +0 -0
  46. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_checkpoints.py +0 -0
  47. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_dispatching.py +0 -0
  48. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_durability.py +0 -0
  49. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_edit_dispatched.py +0 -0
  50. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_fenced_postgres.py +0 -0
  51. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_gpt_live.py +0 -0
  52. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_imports.py +0 -0
  53. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_input_required.py +0 -0
  54. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_interest.py +0 -0
  55. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_langgraph.py +0 -0
  56. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_linear_thread.py +0 -0
  57. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_meta.py +0 -0
  58. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_mint_concurrency.py +0 -0
  59. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_outcomes.py +0 -0
  60. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_placement.py +0 -0
  61. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_prepare_hooks.py +0 -0
  62. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_restore.py +0 -0
  63. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_rewind_during_run.py +0 -0
  64. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_admission.py +0 -0
  65. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_crash_sweep.py +0 -0
  66. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_eviction.py +0 -0
  67. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_ownership.py +0 -0
  68. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_run_sync.py +0 -0
  69. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_settle.py +0 -0
  70. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_stop_continue.py +0 -0
  71. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_stopping_window.py +0 -0
  72. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_threads.py +0 -0
  73. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.0}/tests/test_undo.py +0 -0
@@ -2,6 +2,7 @@ node_modules
2
2
  .next
3
3
  dist
4
4
  dist-test
5
+ coverage
5
6
  .wrangler
6
7
  .DS_Store
7
8
  .env
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.38.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.16,>=0.15.2
9
- Requires-Dist: statewire<0.20,>=0.19.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.38.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.19.0,<0.20", "pinned>=0.15.2,<0.16"]
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 _user_text(message: dict[str, Any]) -> str:
36
- return "\n\n".join(part["text"] for part in message["parts"] if part["type"] == "text")
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=_user_text(message)))
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=_user_text(message)))
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._touched.add((ns, message_id))
237
- assembler = self._assemblers[ns] = PartsAssembler(lambda: self.tree.get(ns, message_id))
238
- self._thread_state(ns, status="streaming")
239
- self._changed()
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 abandon_turn(self) -> None:
420
- """Discard a stream capture and any fork planned for the abandoned turn."""
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(refetch=outcome is not None)
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 admission tag; the wire state is public."""
122
- return {k: v for k, v in message.items() if k != "~admit"}
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
- parent_id = message.get("parentId", _ABSENT)
202
- if parent_id is not _ABSENT and parent_id is not None and not isinstance(parent_id, str):
203
- raise _reject("invalid-message", "message.parentId must be a string")
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 = [m["id"] for m in stripped if m["id"] not in known]
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
- {k: v for k, v in message.items() if k not in ("meta", "~admit")}
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 = self._error_of(exc)
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, self._stamped(e.message, e.meta)))
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
- if e.principal is not None:
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
- if not isinstance(message, dict) or not isinstance(message.get("id"), str):
1545
- raise TypeError(f"a steer message needs a string id, got {message!r}")
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 = [item["id"] for item in items if item["id"] not in known]
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,