harness-sdk-python 0.37.0__tar.gz → 0.37.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 (62) hide show
  1. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/__init__.py +10 -1
  4. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/deepagents_host.py +31 -0
  5. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/interest.py +6 -0
  6. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/protocol.py +2 -0
  7. harness_sdk_python-0.37.1/src/harness_sdk/threads.py +170 -0
  8. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/wire.py +43 -41
  9. harness_sdk_python-0.37.1/tests/test_threads.py +169 -0
  10. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_wire.py +13 -10
  11. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/.gitignore +0 -0
  12. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/README.md +0 -0
  13. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/examples/__init__.py +0 -0
  14. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/examples/runs_app.py +0 -0
  15. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/checkpoints.py +0 -0
  16. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/deepagents.py +0 -0
  17. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/fenced_postgres.py +0 -0
  18. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/langgraph.py +0 -0
  19. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/linear_thread.py +0 -0
  20. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/messages.py +0 -0
  21. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/parts.py +0 -0
  22. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/run_manager.py +0 -0
  23. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/voice.py +0 -0
  24. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/run_helpers.py +0 -0
  25. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_admit_steer.py +0 -0
  26. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_applied_visibility.py +0 -0
  27. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_batches.py +0 -0
  28. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_branch_anchor.py +0 -0
  29. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_checkpoints.py +0 -0
  30. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_deepagents_thread.py +0 -0
  31. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_dispatching.py +0 -0
  32. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_durability.py +0 -0
  33. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_edit_dispatched.py +0 -0
  34. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_edit_reload.py +0 -0
  35. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_enqueue.py +0 -0
  36. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_fenced_postgres.py +0 -0
  37. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_input_required.py +0 -0
  38. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_interest.py +0 -0
  39. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_langgraph.py +0 -0
  40. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_linear_thread.py +0 -0
  41. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_meta.py +0 -0
  42. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_mint_concurrency.py +0 -0
  43. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_outcomes.py +0 -0
  44. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_parts.py +0 -0
  45. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_placement.py +0 -0
  46. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_prepare_hooks.py +0 -0
  47. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_restore.py +0 -0
  48. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_rewind_during_run.py +0 -0
  49. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_admission.py +0 -0
  50. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_crash_sweep.py +0 -0
  51. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_eviction.py +0 -0
  52. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_ownership.py +0 -0
  53. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_sync.py +0 -0
  54. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_settle.py +0 -0
  55. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_steer.py +0 -0
  56. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_stop_continue.py +0 -0
  57. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_stopping_window.py +0 -0
  58. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_undo.py +0 -0
  59. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_voice.py +0 -0
  60. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_voice_delegation.py +0 -0
  61. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_voice_frontend_tools.py +0 -0
  62. {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/voice_helpers.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.37.0
3
+ Version: 0.37.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
8
  Requires-Dist: pinned<0.16,>=0.15.2
9
- Requires-Dist: statewire<0.19,>=0.18.0
9
+ Requires-Dist: statewire<0.19,>=0.18.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.37.0"
3
+ version = "0.37.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.18.0,<0.19", "pinned>=0.15.2,<0.16"]
8
+ dependencies = ["statewire>=0.18.1,<0.19", "pinned>=0.15.2,<0.16"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -1,4 +1,4 @@
1
- from .protocol import HARNESS_PROTOCOL
1
+ from .protocol import HARNESS_PROTOCOL, HARNESS_THREADS_PROTOCOL
2
2
  from .checkpoints import (
3
3
  CheckpointIndex,
4
4
  CheckpointStore,
@@ -21,6 +21,7 @@ from .messages import MAIN_NS, MessageTree
21
21
  from .parts import PartsAssembler
22
22
  from .linear_thread import linear_thread
23
23
  from .run_manager import RunManager, RunManagerDescriptor, run_manager
24
+ from .threads import DuplicateThread, HarnessThreads, MemoryThreadStore, ThreadRecord, ThreadStatus, ThreadStore, UnknownThread
24
25
  from .wire import CONTEXT, INTEREST, HarnessMessages
25
26
  from .voice import (
26
27
  VoiceManager,
@@ -37,6 +38,14 @@ from .voice import (
37
38
  __all__ = [
38
39
  "CONTEXT",
39
40
  "HARNESS_PROTOCOL",
41
+ "HARNESS_THREADS_PROTOCOL",
42
+ "HarnessThreads",
43
+ "MemoryThreadStore",
44
+ "ThreadRecord",
45
+ "ThreadStatus",
46
+ "ThreadStore",
47
+ "DuplicateThread",
48
+ "UnknownThread",
40
49
  "HarnessMessages",
41
50
  "INTEREST",
42
51
  "CheckpointIndex",
@@ -13,9 +13,26 @@ from .checkpoints import CheckpointIndex
13
13
  from .deepagents import DeepagentsThread
14
14
  from .langgraph import FrontierCheckpointer
15
15
  from .protocol import HARNESS_PROTOCOL
16
+ from .messages import MAIN_NS
16
17
  from .run_manager import RunManager, run_manager
17
18
  from .wire import HarnessMessages
18
19
 
20
+ TITLE_LENGTH = 80
21
+
22
+
23
+ def first_user_text(tree: Any) -> str | None:
24
+ """Text of the first user message on the main head chain, cut to ``TITLE_LENGTH``."""
25
+ thread = tree.threads.get(MAIN_NS)
26
+ if thread is None or thread.head is None:
27
+ return None
28
+ for message_id in thread.chain(thread.head):
29
+ message = thread.messages[message_id]
30
+ if message["role"] != "user":
31
+ continue
32
+ text = " ".join(p["text"] for p in message["parts"] if p.get("type") == "text").strip()
33
+ return text[:TITLE_LENGTH] if text else None
34
+ return None
35
+
19
36
 
20
37
  class DeepagentsHost(HarnessMessages, Statewire):
21
38
  """One thread: ``build_agent(checkpointer)`` returns the compiled deepagents graph."""
@@ -23,6 +40,8 @@ class DeepagentsHost(HarnessMessages, Statewire):
23
40
  protocols = (HARNESS_PROTOCOL,)
24
41
  checkpointer: Any = None
25
42
  index: CheckpointIndex | None = None
43
+ #: Path of the ``HarnessThreads`` instance this thread lists itself on, e.g. ``"/thread-lists/default"``. Alpha.
44
+ thread_list: str | None = None
26
45
 
27
46
  def build_agent(self, checkpointer: Any) -> Any:
28
47
  raise NotImplementedError(f"{type(self).__name__}.build_agent")
@@ -61,6 +80,7 @@ class DeepagentsHost(HarnessMessages, Statewire):
61
80
  except BaseException:
62
81
  self.messages.abandon_turn()
63
82
  raise
83
+ await self._touch_thread_list()
64
84
  if outcome is not None:
65
85
  return outcome
66
86
  interrupt = self.messages.interrupt
@@ -75,6 +95,17 @@ class DeepagentsHost(HarnessMessages, Statewire):
75
95
  payload = {"messages": steered}
76
96
  config = self.messages.config()
77
97
 
98
+ async def _touch_thread_list(self) -> None:
99
+ if self.thread_list is None:
100
+ return
101
+ body: dict[str, Any] = {"threadId": self.id}
102
+ title = first_user_text(self.tree)
103
+ if title is not None:
104
+ body["title"] = title
105
+ response = await self.cluster.post(f"{self.thread_list}/touch", json=body)
106
+ if response.status_code != 200:
107
+ raise RuntimeError(f"{self.thread_list}/touch answered {response.status_code}: {response.text}")
108
+
78
109
  async def _stream_turn(self, ctx: RunManager.RunContext, payload: Any, config: dict[str, Any]) -> Any:
79
110
  stream = self.agent.astream(
80
111
  payload, config=config, stream_mode=["messages", "updates", "checkpoints"], subgraphs=True
@@ -11,6 +11,12 @@ from .messages import MessageTree, Thread
11
11
 
12
12
  MAX_WINDOW = 200
13
13
  MAX_INTERESTS = 16
14
+ DEFAULT_WINDOW = 50
15
+
16
+
17
+ def head_request(window: int = DEFAULT_WINDOW) -> dict[str, Any]:
18
+ """The head window of the main thread: every attach starts with it."""
19
+ return {"ns": "main", "before": window, "follow": {"before": window}}
14
20
 
15
21
 
16
22
  class InterestError(ValueError):
@@ -10,3 +10,5 @@ HARNESS_PROTOCOL = Statewire.Protocol(
10
10
  Subprotocol("context", record="ephemeral"),
11
11
  ),
12
12
  )
13
+
14
+ HARNESS_THREADS_PROTOCOL = Statewire.Protocol("harness-sdk-threads", version="2026-09-13", min_version="2026-09-13")
@@ -0,0 +1,170 @@
1
+ """HarnessThreads: a Statewire host serving one thread list over the
2
+ ``harness-sdk-threads`` protocol; the list instance is the store's only writer.
3
+
4
+ .. versionadded:: alpha
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Literal, NotRequired, Protocol, TypedDict
9
+
10
+ from fastapi import HTTPException, Request
11
+ from pinned import Attestation, AuthRejected, attestation, route
12
+ from starlette.requests import HTTPConnection
13
+ from statewire import Statewire, StatewireReject, command
14
+
15
+ from .protocol import HARNESS_THREADS_PROTOCOL
16
+
17
+ ThreadStatus = Literal["regular", "archived"]
18
+
19
+
20
+ class ThreadRecord(TypedDict):
21
+ """One list entry; ``updatedAt`` is ISO-8601.
22
+
23
+ .. versionadded:: alpha
24
+ """
25
+
26
+ id: str
27
+ title: NotRequired[str]
28
+ status: ThreadStatus
29
+ updatedAt: str
30
+
31
+
32
+ class UnknownThread(KeyError):
33
+ """The store has no thread with that id."""
34
+
35
+
36
+ class DuplicateThread(ValueError):
37
+ """The store already has a thread with that id."""
38
+
39
+
40
+ class ThreadStore(Protocol):
41
+ """Backing store of one thread list; ``list`` is sorted by ``updatedAt`` descending.
42
+
43
+ .. versionadded:: alpha
44
+ """
45
+
46
+ async def list(self) -> list[ThreadRecord]: ...
47
+ async def create(self, thread_id: str) -> None: ...
48
+ async def rename(self, thread_id: str, title: str) -> None: ...
49
+ async def set_status(self, thread_id: str, status: ThreadStatus) -> None: ...
50
+ async def delete(self, thread_id: str) -> None: ...
51
+ async def touch(self, thread_id: str, *, title: str | None = None) -> None:
52
+ """Upsert ``thread_id`` at the top of the list; ``title`` applies only while the thread has none."""
53
+ ...
54
+
55
+
56
+ def _now() -> str:
57
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
58
+
59
+
60
+ class MemoryThreadStore:
61
+ """In-process ``ThreadStore``.
62
+
63
+ .. versionadded:: alpha
64
+ """
65
+
66
+ def __init__(self) -> None:
67
+ self._threads: dict[str, ThreadRecord] = {}
68
+
69
+ def _get(self, thread_id: str) -> ThreadRecord:
70
+ record = self._threads.get(thread_id)
71
+ if record is None:
72
+ raise UnknownThread(thread_id)
73
+ return record
74
+
75
+ async def list(self) -> list[ThreadRecord]:
76
+ return sorted((dict(r) for r in self._threads.values()), key=lambda r: r["updatedAt"], reverse=True)
77
+
78
+ async def create(self, thread_id: str) -> None:
79
+ if thread_id in self._threads:
80
+ raise DuplicateThread(thread_id)
81
+ self._threads[thread_id] = {"id": thread_id, "status": "regular", "updatedAt": _now()}
82
+
83
+ async def rename(self, thread_id: str, title: str) -> None:
84
+ self._get(thread_id)["title"] = title
85
+
86
+ async def set_status(self, thread_id: str, status: ThreadStatus) -> None:
87
+ self._get(thread_id)["status"] = status
88
+
89
+ async def delete(self, thread_id: str) -> None:
90
+ self._get(thread_id)
91
+ del self._threads[thread_id]
92
+
93
+ async def touch(self, thread_id: str, *, title: str | None = None) -> None:
94
+ record = self._threads.setdefault(thread_id, {"id": thread_id, "status": "regular", "updatedAt": ""})
95
+ record["updatedAt"] = _now()
96
+ if title is not None and "title" not in record:
97
+ record["title"] = title
98
+
99
+
100
+ def _thread_id(params: Any) -> str:
101
+ if not isinstance(params, dict) or not isinstance(params.get("threadId"), str) or not params["threadId"]:
102
+ raise StatewireReject("threadId must be a non-empty string", code="invalid-params")
103
+ return params["threadId"]
104
+
105
+
106
+ class HarnessThreads(Statewire):
107
+ """One thread list; ``thread_store()`` names its store (in-memory per instance by default).
108
+
109
+ .. versionadded:: alpha
110
+ """
111
+
112
+ protocols = (HARNESS_THREADS_PROTOCOL,)
113
+
114
+ def thread_store(self) -> ThreadStore:
115
+ return MemoryThreadStore()
116
+
117
+ async def lifespan(self):
118
+ self.store = self.thread_store()
119
+ self.state = {"threads": await self.store.list()}
120
+ yield
121
+
122
+ async def _apply(self, action: Any) -> None:
123
+ try:
124
+ await action
125
+ except UnknownThread as error:
126
+ raise StatewireReject(f"unknown thread {error.args[0]}", code="unknown-thread") from None
127
+ except DuplicateThread as error:
128
+ raise StatewireReject(f"thread {error.args[0]} already exists", code="duplicate-id") from None
129
+ self.state["threads"] = await self.store.list()
130
+
131
+ async def _instance_auth(self, conn: HTTPConnection) -> Attestation.Instance:
132
+ caller = attestation(conn)
133
+ if not isinstance(caller, Attestation.Instance):
134
+ raise AuthRejected("touch requires an attested instance caller", status_code=403)
135
+ return caller
136
+
137
+ @route.post("/touch", auth=_instance_auth)
138
+ async def touch(self, request: Request) -> dict[str, bool]:
139
+ """``POST /touch`` ``{"threadId", "title"?}`` from another instance: upsert at the top of the list."""
140
+ body = await request.json()
141
+ thread_id = body.get("threadId") if isinstance(body, dict) else None
142
+ title = body.get("title") if isinstance(body, dict) else None
143
+ if not isinstance(thread_id, str) or not thread_id or not (title is None or isinstance(title, str)):
144
+ raise HTTPException(status_code=400, detail="body must be {threadId: str, title?: str}")
145
+ await self._apply(self.store.touch(thread_id, title=title))
146
+ return {"ok": True}
147
+
148
+ @command("thread/create")
149
+ async def thread_create(self, params: Any) -> None:
150
+ await self._apply(self.store.create(_thread_id(params)))
151
+
152
+ @command("thread/rename")
153
+ async def thread_rename(self, params: Any) -> None:
154
+ thread_id = _thread_id(params)
155
+ title = params.get("title")
156
+ if not isinstance(title, str):
157
+ raise StatewireReject("title must be a string", code="invalid-params")
158
+ await self._apply(self.store.rename(thread_id, title))
159
+
160
+ @command("thread/archive")
161
+ async def thread_archive(self, params: Any) -> None:
162
+ await self._apply(self.store.set_status(_thread_id(params), "archived"))
163
+
164
+ @command("thread/unarchive")
165
+ async def thread_unarchive(self, params: Any) -> None:
166
+ await self._apply(self.store.set_status(_thread_id(params), "regular"))
167
+
168
+ @command("thread/delete")
169
+ async def thread_delete(self, params: Any) -> None:
170
+ await self._apply(self.store.delete(_thread_id(params)))
@@ -1,7 +1,7 @@
1
- """HarnessMessages: the harness message tree on the wire. Interests are
2
- per-attach ephemeral documents under ``harness-sdk/interest``; the messages
3
- they cover are shared keyed ``harness-sdk`` documents included per attach;
4
- ``harness-sdk/context`` is the synced client context.
1
+ """HarnessMessages: the harness message tree on the wire. Each attach's
2
+ interests are one ephemeral main document under ``harness-sdk/interest``;
3
+ the messages they cover are shared keyed ``harness-sdk`` documents included
4
+ per attach; ``harness-sdk/context`` is the synced client context.
5
5
  """
6
6
 
7
7
  from functools import cached_property
@@ -10,20 +10,21 @@ from typing import Any
10
10
  from statepatch import plain
11
11
  from statewire import StatewireAttach, StatewireReject, command, synced_document
12
12
 
13
- from .interest import InterestError, check_request, covered, resolve
13
+ from .interest import MAX_INTERESTS, InterestError, check_request, covered, head_request, resolve
14
14
  from .messages import MessageTree
15
15
 
16
16
  INTEREST = "harness-sdk/interest"
17
17
  CONTEXT = "harness-sdk/context"
18
- _REQUEST_KEYS = ("ns", "at", "before", "after", "follow")
18
+ _EMPTY_WINDOW = {"chain": [], "more": {"before": False, "after": False}}
19
19
 
20
20
 
21
21
  class _Interests:
22
- """One attach's interests: request and document proxy by id, plus its included message keys."""
22
+ """One attach's requests, its interest document proxy, and its included message keys."""
23
23
 
24
24
  def __init__(self, attach: StatewireAttach) -> None:
25
25
  self.attach = attach
26
- self.requests: dict[str, tuple[dict[str, Any], Any]] = {}
26
+ self.requests: list[dict[str, Any]] = []
27
+ self.proxy: Any = None
27
28
  self.included: set[tuple[str, str]] = set()
28
29
 
29
30
 
@@ -32,6 +33,10 @@ class HarnessMessages:
32
33
 
33
34
  context = synced_document(CONTEXT)
34
35
 
36
+ @property
37
+ def state_key(self) -> tuple[str, ...]:
38
+ return (self.id,)
39
+
35
40
  @cached_property
36
41
  def tree(self) -> MessageTree:
37
42
  return MessageTree()
@@ -44,55 +49,52 @@ class HarnessMessages:
44
49
  def _mounted_messages(self) -> dict[tuple[str, str], Any]:
45
50
  return {}
46
51
 
52
+ def attaching(self, attach: StatewireAttach) -> None:
53
+ interests = self._interests[attach.id] = _Interests(attach)
54
+ interests.requests = [head_request()]
55
+ self.refresh_messages()
56
+
47
57
  def detached(self, attach: StatewireAttach) -> None:
48
58
  if self._interests.pop(attach.id, None) is not None:
49
59
  self.refresh_messages()
50
60
 
51
61
  @command("set", protocol=INTEREST)
52
- async def _interest_set(self, id: str, request: Any, *, ctx: Any) -> None:
53
- if not isinstance(id, str) or not id:
54
- raise StatewireReject("interest id must be a non-empty string", code="invalid-interest")
62
+ async def _interest_set(self, requests: Any, *, ctx: Any) -> None:
63
+ if not isinstance(requests, list) or len(requests) > MAX_INTERESTS:
64
+ raise StatewireReject(
65
+ f"interests must be an array of at most {MAX_INTERESTS} requests", code="invalid-interest"
66
+ )
55
67
  try:
56
- normalized = check_request(request)
57
- resolution = resolve(self.tree, normalized)
68
+ normalized = [check_request(request) for request in requests]
69
+ for request in normalized:
70
+ resolve(self.tree, request)
58
71
  except InterestError as exc:
59
72
  raise StatewireReject(exc.message, code=exc.code) from None
60
- interests = self._interests.get(ctx.attach.id)
61
- if interests is None:
62
- interests = self._interests[ctx.attach.id] = _Interests(ctx.attach)
63
- existing = interests.requests.get(id)
64
- if existing is None:
65
- proxy = ctx.attach.mount(INTEREST, {**normalized, **resolution}, key=[self.id, id])
66
- else:
67
- proxy = existing[1]
68
- for key in _REQUEST_KEYS:
69
- if key in normalized:
70
- proxy[key] = normalized[key]
71
- elif key in plain(proxy):
72
- del proxy[key]
73
- interests.requests[id] = (normalized, proxy)
73
+ self._interests[ctx.attach.id].requests = normalized
74
74
  self.refresh_messages()
75
75
 
76
- @command("remove", protocol=INTEREST)
77
- async def _interest_remove(self, id: str, *, ctx: Any) -> None:
78
- interests = self._interests.get(ctx.attach.id)
79
- if interests is None or id not in interests.requests:
80
- raise StatewireReject(f"unknown interest {id!r}", code="unknown-interest")
81
- del interests.requests[id]
82
- ctx.attach.unmount(INTEREST, key=[self.id, id])
83
- self.refresh_messages()
76
+ def _windows(self, requests: list[dict[str, Any]]) -> list[dict[str, Any]]:
77
+ windows = []
78
+ for request in requests:
79
+ try:
80
+ resolution = resolve(self.tree, request)
81
+ except InterestError:
82
+ resolution = _EMPTY_WINDOW
83
+ windows.append({**request, **resolution})
84
+ return windows
84
85
 
85
86
  def refresh_messages(self) -> None:
86
87
  """Re-resolve every interest; mount, include, exclude and unmount message documents to match."""
87
88
  wanted: set[tuple[str, str]] = set()
88
89
  for interests in self._interests.values():
90
+ windows = self._windows(interests.requests)
91
+ if interests.proxy is None:
92
+ interests.proxy = interests.attach.mount(INTEREST, {"windows": windows}, main=True)
93
+ elif plain(interests.proxy)["windows"] != windows:
94
+ interests.proxy["windows"] = windows
89
95
  attach_wanted: set[tuple[str, str]] = set()
90
- for request, proxy in interests.requests.values():
91
- resolution = resolve(self.tree, request)
92
- for key, value in resolution.items():
93
- if plain(proxy).get(key) != value:
94
- proxy[key] = value
95
- attach_wanted |= covered(request, resolution)
96
+ for window in windows:
97
+ attach_wanted |= covered(window, window)
96
98
  for ns, message_id in attach_wanted - interests.included:
97
99
  self._mount_message(ns, message_id)
98
100
  interests.attach.include("harness-sdk", [self.id, ns, message_id])
@@ -0,0 +1,169 @@
1
+ """Contract: HarnessThreads serves one ``harness-sdk-threads`` main document, newest first; its ``thread/*`` commands edit the list and ``POST /touch`` upserts from an attested sibling instance only."""
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+
6
+ import httpx
7
+ import pytest
8
+ from fastapi import FastAPI
9
+ from pinned import PinnedAPI, PinnedHost, PinnedWorker, route
10
+ from statewire.protocol import Offer, VersionRange, headers as protocol_headers
11
+ from statewire_helpers import apply_ops, cmd_on, stream_of
12
+
13
+ from harness_sdk.deepagents_host import first_user_text
14
+ from harness_sdk.messages import MessageTree
15
+ from harness_sdk.threads import HarnessThreads
16
+
17
+ pytestmark = pytest.mark.anyio
18
+
19
+ NEGOTIATION = protocol_headers(VersionRange(), (Offer("harness-sdk-threads", VersionRange()),))
20
+
21
+
22
+ class Toucher(PinnedAPI):
23
+ """A sibling host's instance touching the list at /threads/t1 through the cluster."""
24
+
25
+ @route.post("/touch")
26
+ async def touch(self, thread_id: str, title: str | None = None) -> dict:
27
+ body = {"threadId": thread_id, **({"title": title} if title is not None else {})}
28
+ response = await self.cluster.post("/threads/t1/touch", json=body)
29
+ return {"status": response.status_code}
30
+
31
+
32
+ @asynccontextmanager
33
+ async def thread_list():
34
+ worker = PinnedWorker()
35
+ lists = PinnedHost(HarnessThreads, worker=worker, namespace="/threads")
36
+ callers = PinnedHost(Toucher, worker=worker, namespace="/callers")
37
+ app = FastAPI()
38
+ app.include_router(worker)
39
+ app.include_router(lists)
40
+ app.include_router(callers)
41
+ async with lists.lifespan(app), callers.lifespan(app):
42
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test", headers=NEGOTIATION) as client:
43
+ yield app, client
44
+
45
+
46
+ async def touch(client, thread_id, title=None):
47
+ params = {"thread_id": thread_id, **({"title": title} if title is not None else {})}
48
+ response = await client.post("/callers/c/touch", params=params)
49
+ assert response.json() == {"status": 200}, response.text
50
+
51
+
52
+ def ids(state):
53
+ return [t["id"] for t in state["threads"]]
54
+
55
+
56
+ async def attached(app, client_id="c1"):
57
+ stream = stream_of(app, client_id=client_id, headers=NEGOTIATION)
58
+ await stream.__aenter__()
59
+ snapshot = await stream.next_event()
60
+ state = apply_ops({}, snapshot)
61
+ return stream, state
62
+
63
+
64
+ async def snapshot(app, client_id):
65
+ stream, state = await attached(app, client_id)
66
+ await stream.__aexit__(None, None, None)
67
+ return state
68
+
69
+
70
+ async def synced(stream, state):
71
+ """Fold envelopes until one carries document ops."""
72
+ while True:
73
+ env = await stream.next_event()
74
+ if env.get("ops"):
75
+ for op in env["ops"]:
76
+ if op.get("op") == "replace" and op.get("path") == []:
77
+ state.clear()
78
+ state.update(op["value"])
79
+ else:
80
+ apply_ops(state, [op])
81
+ return state
82
+
83
+
84
+ def statement(seq, method, **params):
85
+ return {"seq": seq, "protocol": "harness-sdk-threads", "method": method, "params": [params]}
86
+
87
+
88
+ async def test_snapshot_lists_newest_first():
89
+ async with thread_list() as (app, client):
90
+ await touch(client, "a", title="First")
91
+ await asyncio.sleep(0.002)
92
+ await touch(client, "b")
93
+ state = await snapshot(app, "c1")
94
+ assert ids(state) == ["b", "a"]
95
+ assert state["threads"][1] == {"id": "a", "title": "First", "status": "regular", "updatedAt": state["threads"][1]["updatedAt"]}
96
+
97
+
98
+ async def test_commands_edit_the_document():
99
+ async with thread_list() as (app, client):
100
+ stream, _state = await attached(app)
101
+ try:
102
+ post = lambda seq, method, **params: cmd_on( # noqa: E731
103
+ stream, client, statement(seq, method, **params), client_id="c1"
104
+ )
105
+ assert (await post(1, "thread/create", threadId="t1"))["type"] == "result"
106
+ assert (await post(2, "thread/rename", threadId="t1", title="Renamed"))["type"] == "result"
107
+ assert (await post(3, "thread/archive", threadId="t1"))["type"] == "result"
108
+ listed = (await snapshot(app, "c2"))["threads"]
109
+ assert listed[0]["title"] == "Renamed" and listed[0]["status"] == "archived"
110
+ assert (await post(4, "thread/unarchive", threadId="t1"))["type"] == "result"
111
+ assert (await snapshot(app, "c3"))["threads"][0]["status"] == "regular"
112
+ assert (await post(5, "thread/delete", threadId="t1"))["type"] == "result"
113
+ assert (await snapshot(app, "c4"))["threads"] == []
114
+ finally:
115
+ await stream.__aexit__(None, None, None)
116
+
117
+
118
+ async def test_rejections():
119
+ async with thread_list() as (app, client):
120
+ await touch(client, "t1")
121
+ stream, _state = await attached(app)
122
+ try:
123
+ verdicts = [
124
+ await cmd_on(stream, client, statement(1, "thread/create", threadId="t1"), client_id="c1"),
125
+ await cmd_on(stream, client, statement(2, "thread/rename", threadId="zz", title="x"), client_id="c1"),
126
+ await cmd_on(stream, client, statement(3, "thread/archive", threadId=""), client_id="c1"),
127
+ await cmd_on(stream, client, statement(4, "thread/rename", threadId="t1", title=3), client_id="c1"),
128
+ ]
129
+ assert [(v["type"], v.get("code")) for v in verdicts] == [
130
+ ("rejected", "duplicate-id"),
131
+ ("rejected", "unknown-thread"),
132
+ ("rejected", "invalid-params"),
133
+ ("rejected", "invalid-params"),
134
+ ]
135
+ finally:
136
+ await stream.__aexit__(None, None, None)
137
+
138
+
139
+ async def test_touch_from_a_sibling_instance_reaches_attached_streams():
140
+ async with thread_list() as (app, client):
141
+ stream, state = await attached(app)
142
+ try:
143
+ assert ids(state) == []
144
+ await touch(client, "t9", title="From the thread host")
145
+ await synced(stream, state)
146
+ assert ids(state) == ["t9"]
147
+ assert state["threads"][0]["title"] == "From the thread host"
148
+ await touch(client, "t9", title="Ignored: title is set once")
149
+ await synced(stream, state)
150
+ assert state["threads"][0]["title"] == "From the thread host"
151
+ finally:
152
+ await stream.__aexit__(None, None, None)
153
+
154
+
155
+ async def test_touch_refuses_unattested_callers_and_bad_bodies():
156
+ async with thread_list() as (app, client):
157
+ response = await client.post("/threads/t1/touch", json={"threadId": "t1"})
158
+ assert response.status_code == 403
159
+ response = await client.post("/callers/c/touch", params={"thread_id": ""})
160
+ assert response.json() == {"status": 400}
161
+ assert ids(await snapshot(app, "c1")) == []
162
+
163
+
164
+ def test_first_user_text_reads_the_head_chain():
165
+ tree = MessageTree()
166
+ tree.add("main", {"id": "u1", "parentId": None, "role": "user", "parts": [{"type": "text", "text": " Hello "}, {"type": "text", "text": "x" * 100}]})
167
+ tree.add("main", {"id": "a1", "parentId": "u1", "role": "assistant", "parts": []})
168
+ assert first_user_text(tree) == ("Hello " + "x" * 100)[:80]
169
+ assert first_user_text(MessageTree()) is None
@@ -1,4 +1,4 @@
1
- """Contract: HarnessMessages serves interests as ephemeral documents, mounts and includes the covered messages per attach, and unmounts what no attach covers."""
1
+ """Contract: HarnessMessages serves each attach's interests as one ephemeral main document, mounts and includes the covered messages per attach, and unmounts what no attach covers."""
2
2
 
3
3
  import asyncio
4
4
  from typing import Any
@@ -60,8 +60,11 @@ class Wire:
60
60
  self.docs[doc] = (protocol, key, client_apply_ops(value, [mutation]))
61
61
  self.answers.extend(envelope.get("cmd", []))
62
62
 
63
- def keyed(self, protocol: str) -> dict[tuple[str, ...], Any]:
64
- return {tuple(key): value for p, key, value in self.docs.values() if p == protocol and key is not None}
63
+ def messages(self, protocol: str) -> dict[tuple[str, ...], Any]:
64
+ return {tuple(key): value for p, key, value in self.docs.values() if p == protocol and key is not None and len(key) == 3}
65
+
66
+ def window(self, index: int) -> Any:
67
+ return next(value for p, _key, value in self.docs.values() if p == "harness-sdk/interest")["windows"][index]
65
68
 
66
69
  async def until(self, predicate, what: str) -> None:
67
70
  try:
@@ -80,13 +83,13 @@ async def test_interests_mount_covered_messages_follow_spawns_and_unmount_on_det
80
83
  await wire.pump()
81
84
  response = await post_frame(
82
85
  client,
83
- {"cmd": [{"protocol": "harness-sdk/interest", "method": "set", "params": ["i0", {"ns": "main", "before": 10, "follow": {"before": 10}}], "seq": 1}]},
86
+ {"cmd": [{"protocol": "harness-sdk/interest", "method": "set", "params": [[{"ns": "main", "before": 10, "follow": {"before": 10}}]], "seq": 1}]},
84
87
  client_id="c1",
85
88
  )
86
89
  assert response.status_code == 200, response.text
87
- await wire.until(lambda: len(wire.keyed("harness-sdk")) == 2, "messages included")
88
- assert set(wire.keyed("harness-sdk")) == {("t1", "main", "u1"), ("t1", "main", "a1")}
89
- interest = wire.keyed("harness-sdk/interest")[("t1", "i0")]
90
+ await wire.until(lambda: len(wire.messages("harness-sdk")) == 2, "messages included")
91
+ assert set(wire.messages("harness-sdk")) == {("t1", "main", "u1"), ("t1", "main", "a1")}
92
+ interest = wire.window(0)
90
93
  assert [e["id"] for e in interest["chain"]] == ["u1", "a1"]
91
94
  assert interest["follows"] == {}
92
95
 
@@ -96,10 +99,10 @@ async def test_interests_mount_covered_messages_follow_spawns_and_unmount_on_det
96
99
  host.tree.get("main", "a1")["parts"][0]["metadata"] = {"ns": "sub"}
97
100
  host.tree.add("sub", {"id": "s1", "parentId": None, "role": "user", "parts": [{"type": "text", "text": "go"}]})
98
101
  host.refresh_messages()
99
- await wire.until(lambda: ("t1", "sub", "s1") in wire.keyed("harness-sdk"), "followed message included")
100
- interest = wire.keyed("harness-sdk/interest")[("t1", "i0")]
102
+ await wire.until(lambda: ("t1", "sub", "s1") in wire.messages("harness-sdk"), "followed message included")
103
+ interest = wire.window(0)
101
104
  assert [e["id"] for e in interest["follows"]["sub"]["chain"]] == ["s1"]
102
- assert wire.keyed("harness-sdk")[("t1", "main", "a1")]["parts"][0]["metadata"] == {"ns": "sub"}
105
+ assert wire.messages("harness-sdk")[("t1", "main", "a1")]["parts"][0]["metadata"] == {"ns": "sub"}
103
106
  async with asyncio.timeout(5):
104
107
  while host._interests:
105
108
  await asyncio.sleep(0.01)