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.
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/PKG-INFO +2 -2
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/pyproject.toml +2 -2
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/__init__.py +10 -1
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/deepagents_host.py +31 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/interest.py +6 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/protocol.py +2 -0
- harness_sdk_python-0.37.1/src/harness_sdk/threads.py +170 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/wire.py +43 -41
- harness_sdk_python-0.37.1/tests/test_threads.py +169 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_wire.py +13 -10
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/.gitignore +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/README.md +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/examples/__init__.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/langgraph.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/messages.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/parts.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/src/harness_sdk/voice.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_deepagents_thread.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_interest.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_langgraph.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_parts.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_admission.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_run_sync.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_undo.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_voice.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.37.0 → harness_sdk_python-0.37.1}/tests/test_voice_frontend_tools.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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):
|
|
@@ -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.
|
|
2
|
-
|
|
3
|
-
they cover are shared keyed ``harness-sdk`` documents included
|
|
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
|
-
|
|
18
|
+
_EMPTY_WINDOW = {"chain": [], "more": {"before": False, "after": False}}
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class _Interests:
|
|
22
|
-
"""One attach's
|
|
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:
|
|
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,
|
|
53
|
-
if not isinstance(
|
|
54
|
-
raise StatewireReject(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
91
|
-
|
|
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
|
|
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
|
|
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": [
|
|
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.
|
|
88
|
-
assert set(wire.
|
|
89
|
-
interest = wire.
|
|
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.
|
|
100
|
-
interest = wire.
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|