harness-sdk-python 0.38.0__tar.gz → 0.39.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/.gitignore +1 -0
  2. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/PKG-INFO +7 -4
  3. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/README.md +4 -1
  4. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/examples/runs_app.py +8 -2
  5. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/pyproject.toml +2 -2
  6. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/deepagents.py +75 -20
  7. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/deepagents_host.py +17 -7
  8. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/parts.py +13 -0
  9. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/protocol.py +1 -1
  10. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/run_manager.py +117 -37
  11. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/voice.py +78 -11
  12. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/voice_thread.py +11 -2
  13. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/wire.py +5 -5
  14. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/run_helpers.py +37 -4
  15. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_admit_steer.py +34 -3
  16. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_branch_anchor.py +129 -0
  17. harness_sdk_python-0.39.1/tests/test_deepagents_approval.py +99 -0
  18. harness_sdk_python-0.39.1/tests/test_deepagents_files.py +123 -0
  19. harness_sdk_python-0.39.1/tests/test_deepagents_outcome.py +133 -0
  20. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_deepagents_thread.py +98 -4
  21. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_deepagents_voice.py +10 -9
  22. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_edit_reload.py +9 -7
  23. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_enqueue.py +45 -0
  24. harness_sdk_python-0.39.1/tests/test_parts.py +96 -0
  25. harness_sdk_python-0.39.1/tests/test_run_id_bounds.py +267 -0
  26. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_steer.py +44 -0
  27. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_voice.py +71 -30
  28. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_voice_delegation.py +22 -7
  29. harness_sdk_python-0.39.1/tests/test_voice_events.py +175 -0
  30. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_wire.py +25 -1
  31. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/voice_helpers.py +4 -0
  32. harness_sdk_python-0.38.0/tests/test_parts.py +0 -49
  33. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/examples/__init__.py +0 -0
  34. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/__init__.py +0 -0
  35. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/checkpoints.py +0 -0
  36. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/fenced_postgres.py +0 -0
  37. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/interest.py +0 -0
  38. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/langgraph.py +0 -0
  39. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/linear_thread.py +0 -0
  40. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/messages.py +0 -0
  41. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/providers/__init__.py +0 -0
  42. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/providers/gpt_live.py +0 -0
  43. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/src/harness_sdk/threads.py +0 -0
  44. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_applied_visibility.py +0 -0
  45. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_batches.py +0 -0
  46. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_checkpoints.py +0 -0
  47. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_dispatching.py +0 -0
  48. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_durability.py +0 -0
  49. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_edit_dispatched.py +0 -0
  50. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_fenced_postgres.py +0 -0
  51. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_gpt_live.py +0 -0
  52. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_imports.py +0 -0
  53. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_input_required.py +0 -0
  54. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_interest.py +0 -0
  55. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_langgraph.py +0 -0
  56. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_linear_thread.py +0 -0
  57. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_meta.py +0 -0
  58. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_mint_concurrency.py +0 -0
  59. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_outcomes.py +0 -0
  60. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_placement.py +0 -0
  61. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_prepare_hooks.py +0 -0
  62. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_restore.py +0 -0
  63. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_rewind_during_run.py +0 -0
  64. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_run_admission.py +0 -0
  65. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_run_crash_sweep.py +0 -0
  66. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_run_eviction.py +0 -0
  67. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_run_ownership.py +0 -0
  68. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_run_sync.py +0 -0
  69. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_settle.py +0 -0
  70. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_stop_continue.py +0 -0
  71. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_stopping_window.py +0 -0
  72. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/tests/test_threads.py +0 -0
  73. {harness_sdk_python-0.38.0 → harness_sdk_python-0.39.1}/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.1
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
7
7
  Requires-Python: <4.0,>=3.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.2
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'
@@ -57,7 +57,10 @@ class RunsHost(Statewire):
57
57
  @command("run/enqueue")
58
58
  async def run_enqueue(self, params, *, ctx, auth_result):
59
59
  return await self.run.enqueue(
60
- params, applied=ctx.applied, auth_result=auth_result
60
+ params,
61
+ applied=ctx.applied,
62
+ auth_result=auth_result,
63
+ version=ctx.version,
61
64
  )
62
65
  ```
63
66
 
@@ -35,7 +35,10 @@ class RunsHost(Statewire):
35
35
  @command("run/enqueue")
36
36
  async def run_enqueue(self, params, *, ctx, auth_result):
37
37
  return await self.run.enqueue(
38
- params, applied=ctx.applied, auth_result=auth_result
38
+ params,
39
+ applied=ctx.applied,
40
+ auth_result=auth_result,
41
+ version=ctx.version,
39
42
  )
40
43
  ```
41
44
 
@@ -158,13 +158,19 @@ class RunsHost(Statewire):
158
158
  @command("run/enqueue")
159
159
  async def run_enqueue(self, params, *, ctx, auth_result):
160
160
  return await self.run.enqueue(
161
- params, applied=ctx.applied, auth_result=auth_result
161
+ params,
162
+ applied=ctx.applied,
163
+ auth_result=auth_result,
164
+ version=ctx.version,
162
165
  )
163
166
 
164
167
  @command("run/steer")
165
168
  async def run_steer(self, params, *, ctx, auth_result):
166
169
  return await self.run.steer(
167
- params, applied=ctx.applied, auth_result=auth_result
170
+ params,
171
+ applied=ctx.applied,
172
+ auth_result=auth_result,
173
+ version=ctx.version,
168
174
  )
169
175
 
170
176
  @command("run/dequeue")
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.38.0"
3
+ version = "0.39.1"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.12,<4.0"
8
- dependencies = ["statewire>=0.19.0,<0.20", "pinned>=0.15.2,<0.16"]
8
+ dependencies = ["statewire>=0.21.2,<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
@@ -179,11 +189,11 @@ class DeepagentsHost(VoiceThread, HarnessMessages, Statewire):
179
189
 
180
190
  @command("run/enqueue")
181
191
  async def run_enqueue(self, params, *, ctx, auth_result):
182
- return await self.run.enqueue(params, applied=ctx.applied, auth_result=auth_result)
192
+ return await self.run.enqueue(params, applied=ctx.applied, auth_result=auth_result, version=ctx.version)
183
193
 
184
194
  @command("run/steer")
185
195
  async def run_steer(self, params, *, ctx, auth_result):
186
- return await self.run.steer(params, applied=ctx.applied, auth_result=auth_result)
196
+ return await self.run.steer(params, applied=ctx.applied, auth_result=auth_result, version=ctx.version)
187
197
 
188
198
  @command("run/dequeue")
189
199
  async def run_dequeue(self, params, *, auth_result):
@@ -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
@@ -3,7 +3,7 @@ from statewire.protocol import Subprotocol
3
3
 
4
4
  HARNESS_PROTOCOL = Statewire.Protocol(
5
5
  "harness-sdk",
6
- version="2026-09-30",
6
+ version="2026-11-02",
7
7
  min_version="2026-09-30",
8
8
  subprotocols=(
9
9
  Subprotocol("interest", record="ephemeral"),