harness-sdk-python 0.25.2__py3-none-any.whl → 0.27.0__py3-none-any.whl
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/__init__.py +16 -0
- harness_sdk/deepagents.py +260 -0
- harness_sdk/run_manager.py +19 -6
- harness_sdk/voice.py +382 -0
- {harness_sdk_python-0.25.2.dist-info → harness_sdk_python-0.27.0.dist-info}/METADATA +1 -1
- harness_sdk_python-0.27.0.dist-info/RECORD +10 -0
- harness_sdk_python-0.25.2.dist-info/RECORD +0 -8
- {harness_sdk_python-0.25.2.dist-info → harness_sdk_python-0.27.0.dist-info}/WHEEL +0 -0
harness_sdk/__init__.py
CHANGED
|
@@ -11,23 +11,39 @@ from .checkpoints import (
|
|
|
11
11
|
langgraph_snapshot,
|
|
12
12
|
langgraph_switch_values,
|
|
13
13
|
)
|
|
14
|
+
from .deepagents import DeepagentsMessages
|
|
14
15
|
from .linear_thread import linear_thread
|
|
15
16
|
from .run_manager import RunManager, RunManagerDescriptor, run_manager
|
|
17
|
+
from .voice import (
|
|
18
|
+
VoiceManager,
|
|
19
|
+
VoiceManagerDescriptor,
|
|
20
|
+
VoiceProvider,
|
|
21
|
+
end_voice,
|
|
22
|
+
start_voice,
|
|
23
|
+
voice_manager,
|
|
24
|
+
)
|
|
16
25
|
|
|
17
26
|
__all__ = [
|
|
18
27
|
"CheckpointIndex",
|
|
19
28
|
"CheckpointStore",
|
|
20
29
|
"CheckpointThread",
|
|
30
|
+
"DeepagentsMessages",
|
|
21
31
|
"MemoryCheckpointIndex",
|
|
22
32
|
"Plan",
|
|
23
33
|
"PostgresCheckpointIndex",
|
|
24
34
|
"RunManager",
|
|
25
35
|
"RunManagerDescriptor",
|
|
26
36
|
"Snapshot",
|
|
37
|
+
"VoiceManager",
|
|
38
|
+
"VoiceManagerDescriptor",
|
|
39
|
+
"VoiceProvider",
|
|
27
40
|
"checkpoint_thread",
|
|
41
|
+
"end_voice",
|
|
28
42
|
"langgraph_checkpoints",
|
|
29
43
|
"langgraph_snapshot",
|
|
30
44
|
"langgraph_switch_values",
|
|
31
45
|
"linear_thread",
|
|
32
46
|
"run_manager",
|
|
47
|
+
"start_voice",
|
|
48
|
+
"voice_manager",
|
|
33
49
|
]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""DeepagentsMessages: the ``messages`` slice of a Statewire host, projected
|
|
2
|
+
from a langgraph checkpointer in the deepagents dialect. The checkpointer is
|
|
3
|
+
the source of truth and the wire state is its projection, restamped with
|
|
4
|
+
``branch: {id, siblings}`` metadata after every ingest. A rewind restores the
|
|
5
|
+
projection from the target message's anchor checkpoint; a branch switch clones
|
|
6
|
+
the target branch's tip as the checkpointer's latest checkpoint, so resumption
|
|
7
|
+
and reboot both land on the switched world.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from statepatch import plain
|
|
14
|
+
|
|
15
|
+
from .checkpoints import (
|
|
16
|
+
CheckpointIndex,
|
|
17
|
+
CheckpointStore,
|
|
18
|
+
Snapshot,
|
|
19
|
+
_values_snapshot,
|
|
20
|
+
checkpoint_thread,
|
|
21
|
+
langgraph_snapshot,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _role(message: dict[str, Any]) -> str:
|
|
26
|
+
return "user" if message["type"] == "human" else "assistant"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _user_text(message: dict[str, Any]) -> str:
|
|
30
|
+
return "\n\n".join(
|
|
31
|
+
part["text"] for part in message["parts"] if part["type"] == "text"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _checkpointer_store(
|
|
36
|
+
checkpointer: Any, thread_id: str, checkpoint_ns: str
|
|
37
|
+
) -> CheckpointStore:
|
|
38
|
+
class _Store:
|
|
39
|
+
async def get(self, checkpoint_id: str | None) -> Snapshot | None:
|
|
40
|
+
configurable: dict[str, Any] = {
|
|
41
|
+
"thread_id": thread_id,
|
|
42
|
+
"checkpoint_ns": checkpoint_ns,
|
|
43
|
+
}
|
|
44
|
+
if checkpoint_id is not None:
|
|
45
|
+
configurable["checkpoint_id"] = checkpoint_id
|
|
46
|
+
saved = await checkpointer.aget_tuple({"configurable": configurable})
|
|
47
|
+
if saved is None:
|
|
48
|
+
return None
|
|
49
|
+
return _values_snapshot(
|
|
50
|
+
saved.checkpoint["id"],
|
|
51
|
+
saved.checkpoint["channel_values"],
|
|
52
|
+
"messages",
|
|
53
|
+
"lineage",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return _Store()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DeepagentsMessages:
|
|
60
|
+
"""A host's ``state["messages"]`` over a langgraph checkpointer: boot, turn streaming, rewind, and branch switching for the deepagents dialect."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, checkpointer: Any) -> None:
|
|
63
|
+
if not hasattr(checkpointer, "aget_tuple") or not hasattr(checkpointer, "aput"):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
f"checkpointer must be a langgraph checkpoint saver, got {checkpointer!r}"
|
|
66
|
+
)
|
|
67
|
+
self._checkpointer = checkpointer
|
|
68
|
+
self._captured: dict[str, Any] | None = None
|
|
69
|
+
|
|
70
|
+
async def boot(
|
|
71
|
+
self, state: Any, *, index: CheckpointIndex, thread_id: str
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Adopt ``state["messages"]`` and load the thread's history into it with branch stamps."""
|
|
74
|
+
self._state = state
|
|
75
|
+
self._thread_id = thread_id
|
|
76
|
+
self._store = _checkpointer_store(self._checkpointer, thread_id, "")
|
|
77
|
+
self._thread = await checkpoint_thread(
|
|
78
|
+
store=self._store, index=index, thread_id=thread_id, role=_role
|
|
79
|
+
)
|
|
80
|
+
if self._thread.transcript:
|
|
81
|
+
# the graph carries no lineage channel: re-derive the world path from the tail row
|
|
82
|
+
row = await index.get_message(
|
|
83
|
+
thread_id, "", self._thread.transcript[-1]["id"]
|
|
84
|
+
)
|
|
85
|
+
if row is not None and row["branch_id"] != 0:
|
|
86
|
+
await self._thread.switch_state(row["branch_id"])
|
|
87
|
+
self._restamp()
|
|
88
|
+
|
|
89
|
+
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
90
|
+
"""The thread projection's message meta, for RunManager."""
|
|
91
|
+
return await self._thread.get_message_meta(message_id)
|
|
92
|
+
|
|
93
|
+
async def get_message_child_id(self, parent_id: str) -> str | None:
|
|
94
|
+
"""The thread projection's child id, for RunManager."""
|
|
95
|
+
return await self._thread.get_message_child_id(parent_id)
|
|
96
|
+
|
|
97
|
+
def config(self) -> dict[str, Any]:
|
|
98
|
+
"""The langgraph config addressing this thread."""
|
|
99
|
+
return {"configurable": {"thread_id": self._thread_id}}
|
|
100
|
+
|
|
101
|
+
async def plan_turn(
|
|
102
|
+
self, ctx: Any
|
|
103
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
|
104
|
+
"""The dispatch's langgraph (payload, config): plans the resumption, restores a rewind's window, appends the batch to the wire state, and confirms it applied."""
|
|
105
|
+
config = self.config()
|
|
106
|
+
if ctx.resume:
|
|
107
|
+
return None, config
|
|
108
|
+
plan = await self._thread.plan_run(ctx)
|
|
109
|
+
if ctx.has_rollback:
|
|
110
|
+
base = (
|
|
111
|
+
(await self._snapshot(plan.checkpoint_id)).messages
|
|
112
|
+
if plan.checkpoint_id is not None
|
|
113
|
+
else []
|
|
114
|
+
)
|
|
115
|
+
self._replace([*base, *plan.replay])
|
|
116
|
+
turn: list[Any] = [*plan.replay]
|
|
117
|
+
for message in ctx.messages:
|
|
118
|
+
turn.append(self._append_user(message))
|
|
119
|
+
ctx.applied()
|
|
120
|
+
if plan.fork:
|
|
121
|
+
if plan.checkpoint_id is not None:
|
|
122
|
+
config["configurable"]["checkpoint_id"] = plan.checkpoint_id
|
|
123
|
+
else:
|
|
124
|
+
from langchain_core.messages import RemoveMessage
|
|
125
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
126
|
+
|
|
127
|
+
turn = [RemoveMessage(id=REMOVE_ALL_MESSAGES), *turn]
|
|
128
|
+
return ({"messages": turn} if turn else None), config
|
|
129
|
+
|
|
130
|
+
def append_event(self, namespace: tuple, mode: str, event: Any) -> None:
|
|
131
|
+
"""Fold one ``astream`` event into the wire state; the root checkpoint stream is captured for the turn's ingest."""
|
|
132
|
+
if mode == "checkpoints":
|
|
133
|
+
if namespace == ():
|
|
134
|
+
self._captured = event
|
|
135
|
+
return
|
|
136
|
+
from statewire.langgraph import append_langgraph_event
|
|
137
|
+
|
|
138
|
+
append_langgraph_event(self._state, namespace, mode, event)
|
|
139
|
+
|
|
140
|
+
async def ingest_turn(self, *, refetch: bool = False) -> None:
|
|
141
|
+
"""Ingest the turn's captured checkpoint and restamp; ``refetch`` discards the capture and reads the checkpointer's latest instead."""
|
|
142
|
+
captured, self._captured = self._captured, None
|
|
143
|
+
if refetch or captured is None:
|
|
144
|
+
await self._thread.ingest_latest()
|
|
145
|
+
else:
|
|
146
|
+
await self._thread.ingest(langgraph_snapshot(captured))
|
|
147
|
+
self._restamp()
|
|
148
|
+
|
|
149
|
+
def append_users(self, messages: Any) -> list[dict[str, Any]]:
|
|
150
|
+
"""Append harness user messages to the wire state as human entries, deduplicated by id; returns the langgraph turn entries."""
|
|
151
|
+
return [self._append_user(message) for message in messages]
|
|
152
|
+
|
|
153
|
+
async def append_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
154
|
+
"""Append ``{id, role, text}`` entries as a new checkpoint on the current tip and restamp; the voice commit path."""
|
|
155
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
156
|
+
from langgraph.checkpoint.base import empty_checkpoint
|
|
157
|
+
from langgraph.checkpoint.base.id import uuid6
|
|
158
|
+
|
|
159
|
+
if not messages:
|
|
160
|
+
raise ValueError("messages must be non-empty")
|
|
161
|
+
for message in messages:
|
|
162
|
+
if message["role"] not in ("user", "assistant"):
|
|
163
|
+
raise ValueError(f"unsupported message role: {message['role']!r}")
|
|
164
|
+
entries = [
|
|
165
|
+
(HumanMessage if m["role"] == "user" else AIMessage)(
|
|
166
|
+
id=m["id"], content=m["text"]
|
|
167
|
+
)
|
|
168
|
+
for m in messages
|
|
169
|
+
]
|
|
170
|
+
parent = {"configurable": {"thread_id": self._thread_id, "checkpoint_ns": ""}}
|
|
171
|
+
saved = await self._checkpointer.aget_tuple(parent)
|
|
172
|
+
if saved is None:
|
|
173
|
+
checkpoint = empty_checkpoint()
|
|
174
|
+
step = -1
|
|
175
|
+
else:
|
|
176
|
+
checkpoint = {**saved.checkpoint}
|
|
177
|
+
step = (saved.metadata or {}).get("step", -1)
|
|
178
|
+
parent["configurable"]["checkpoint_id"] = saved.checkpoint["id"]
|
|
179
|
+
version = self._checkpointer.get_next_version(
|
|
180
|
+
checkpoint["channel_versions"].get("messages"), None
|
|
181
|
+
)
|
|
182
|
+
checkpoint = {
|
|
183
|
+
**checkpoint,
|
|
184
|
+
"id": str(uuid6(clock_seq=-2)),
|
|
185
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
186
|
+
"channel_values": {
|
|
187
|
+
**checkpoint["channel_values"],
|
|
188
|
+
"messages": [
|
|
189
|
+
*checkpoint["channel_values"].get("messages", []),
|
|
190
|
+
*entries,
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
"channel_versions": {
|
|
194
|
+
**checkpoint["channel_versions"],
|
|
195
|
+
"messages": version,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
metadata = {"source": "update", "step": step + 1, "parents": {}}
|
|
199
|
+
await self._checkpointer.aput(parent, checkpoint, metadata, {"messages": version})
|
|
200
|
+
await self._thread.ingest_latest()
|
|
201
|
+
self._restamp()
|
|
202
|
+
|
|
203
|
+
async def switch_branch(self, branch_id: int) -> None:
|
|
204
|
+
"""Switch to ``branch_id``: its tip becomes the checkpointer's latest checkpoint and the wire state rebaselines."""
|
|
205
|
+
transcript, lineage = await self._thread.switch_state(branch_id)
|
|
206
|
+
checkpoint_id = await self._clone_tip(self._thread.checkpoint_id)
|
|
207
|
+
await self._thread.ingest(Snapshot(checkpoint_id, transcript, lineage))
|
|
208
|
+
self._restamp()
|
|
209
|
+
|
|
210
|
+
async def _clone_tip(self, checkpoint_id: str) -> str:
|
|
211
|
+
from langgraph.checkpoint.base.id import uuid6
|
|
212
|
+
|
|
213
|
+
parent = {
|
|
214
|
+
"configurable": {
|
|
215
|
+
"thread_id": self._thread_id,
|
|
216
|
+
"checkpoint_ns": "",
|
|
217
|
+
"checkpoint_id": checkpoint_id,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
saved = await self._checkpointer.aget_tuple(parent)
|
|
221
|
+
if saved is None:
|
|
222
|
+
raise LookupError(f"checkpoint {checkpoint_id!r} is not in the checkpointer")
|
|
223
|
+
clone = {
|
|
224
|
+
**saved.checkpoint,
|
|
225
|
+
"id": str(uuid6(clock_seq=-2)),
|
|
226
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
227
|
+
}
|
|
228
|
+
metadata = {
|
|
229
|
+
"source": "update",
|
|
230
|
+
"step": (saved.metadata or {}).get("step", -1) + 1,
|
|
231
|
+
"parents": {},
|
|
232
|
+
}
|
|
233
|
+
written = await self._checkpointer.aput(parent, clone, metadata, {})
|
|
234
|
+
return written["configurable"]["checkpoint_id"]
|
|
235
|
+
|
|
236
|
+
async def _snapshot(self, checkpoint_id: str) -> Snapshot:
|
|
237
|
+
snapshot = await self._store.get(checkpoint_id)
|
|
238
|
+
if snapshot is None:
|
|
239
|
+
raise LookupError(
|
|
240
|
+
f"checkpoint {checkpoint_id!r} is not in the checkpointer"
|
|
241
|
+
)
|
|
242
|
+
return snapshot
|
|
243
|
+
|
|
244
|
+
def _append_user(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
245
|
+
entry = {"id": message["id"], "type": "human", "content": _user_text(message)}
|
|
246
|
+
if not any(m["id"] == entry["id"] for m in plain(self._state["messages"])):
|
|
247
|
+
self._state["messages"].append(entry)
|
|
248
|
+
return entry
|
|
249
|
+
|
|
250
|
+
def _replace(self, messages: list[dict[str, Any]]) -> None:
|
|
251
|
+
from statewire.langgraph import replace_messages
|
|
252
|
+
|
|
253
|
+
replace_messages(self._state, messages)
|
|
254
|
+
|
|
255
|
+
def _restamp(self) -> None:
|
|
256
|
+
stamped = []
|
|
257
|
+
for message in self._thread.transcript:
|
|
258
|
+
branch, siblings = self._thread.sibling_info(message["id"])
|
|
259
|
+
stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
|
|
260
|
+
self._replace(stamped)
|
harness_sdk/run_manager.py
CHANGED
|
@@ -48,6 +48,7 @@ window.
|
|
|
48
48
|
"""
|
|
49
49
|
|
|
50
50
|
import asyncio
|
|
51
|
+
import contextlib
|
|
51
52
|
import logging
|
|
52
53
|
from dataclasses import dataclass, field
|
|
53
54
|
from typing import Any, AsyncIterator, Callable, Coroutine, Iterable, Protocol
|
|
@@ -1610,7 +1611,7 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
1610
1611
|
f"{type(api).__name__} must implement get_message_meta "
|
|
1611
1612
|
"(RunManager.Thread) to host @run_manager"
|
|
1612
1613
|
)
|
|
1613
|
-
self.
|
|
1614
|
+
self._mint: "asyncio.Task[Any] | None" = None
|
|
1614
1615
|
self._mounted: list[RunManager] = []
|
|
1615
1616
|
api.state["runs"] = []
|
|
1616
1617
|
api.state["status"] = "ready"
|
|
@@ -1628,14 +1629,26 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
1628
1629
|
return tray
|
|
1629
1630
|
|
|
1630
1631
|
async def _machine(self, run_id: str) -> tuple[RunManager, bool]:
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1632
|
+
# No await between the live() check and the mint's publication, and no
|
|
1633
|
+
# lock held across storage I/O: same-tick callers await the shared mint
|
|
1634
|
+
# task and merge into the machine it mounts; the creator raises its
|
|
1635
|
+
# mint's failure while followers re-mint and progress.
|
|
1636
|
+
while True:
|
|
1635
1637
|
tray = self.live()
|
|
1636
1638
|
if tray is not None:
|
|
1637
1639
|
return tray, False
|
|
1638
|
-
|
|
1640
|
+
pending = self._mint
|
|
1641
|
+
if pending is None:
|
|
1642
|
+
pending = self._api.create_task(self.get_or_create(run_id))
|
|
1643
|
+
self._mint = pending
|
|
1644
|
+
pending.add_done_callback(self._mint_settled)
|
|
1645
|
+
return await pending, True
|
|
1646
|
+
with contextlib.suppress(Exception):
|
|
1647
|
+
await pending
|
|
1648
|
+
|
|
1649
|
+
def _mint_settled(self, task: "asyncio.Task[Any]") -> None:
|
|
1650
|
+
if self._mint is task:
|
|
1651
|
+
self._mint = None
|
|
1639
1652
|
|
|
1640
1653
|
# ─── Mounted view ───────────────────────────────────────
|
|
1641
1654
|
|
harness_sdk/voice.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""VoiceManager: the harness-sdk voice subsystem, one tray per live session.
|
|
2
|
+
|
|
3
|
+
``@voice_manager()`` on a Statewire host method declares the subsystem: the
|
|
4
|
+
decorated method is the provider factory (``def voice(self, ctx) ->
|
|
5
|
+
VoiceProvider``), ``self.<name>`` is the command surface, and each session
|
|
6
|
+
lives in its own ephemeral tray, minted by ``voice/start`` and deleted when
|
|
7
|
+
the session ends. The host implements ``VoiceManager.Host``: the seed read
|
|
8
|
+
and the turn commit both stay host-owned.
|
|
9
|
+
|
|
10
|
+
``voice/start {offer}`` seeds the provider from the thread transcript (at
|
|
11
|
+
most 128 messages, tail taken) and projects ``state["voice"] = {clientId,
|
|
12
|
+
status, answer, turns}`` — the SDP answer rides the applied state. The
|
|
13
|
+
session is bound to the issuing client's connection: ``voice/end`` from the
|
|
14
|
+
owner, a provider ``closed`` event, or a connection drop ends it. Provider
|
|
15
|
+
turn events accumulate in the in-memory turn buffer projected as
|
|
16
|
+
``state["voice"]["turns"]``; at session end the completed turns commit
|
|
17
|
+
through ``Host.commit_voice_turns`` and in-progress turns drop.
|
|
18
|
+
|
|
19
|
+
Rejections: ``voice-active`` (a session is already live), ``no-voice``
|
|
20
|
+
(no voice manager declared, or ``voice/end`` with no session),
|
|
21
|
+
``not-voice-owner`` (``voice/end`` from another client),
|
|
22
|
+
``voice-unavailable`` (the factory or ``provider.start`` raised).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import functools
|
|
27
|
+
import inspect
|
|
28
|
+
import logging
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Callable, Literal, Protocol, TypedDict
|
|
31
|
+
from uuid import uuid4
|
|
32
|
+
|
|
33
|
+
from pinned.trays import Tray, TrayDescriptor, TrayMapNamespace
|
|
34
|
+
from statepatch import observable
|
|
35
|
+
from statewire import StatewireReject
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_SEED_LIMIT = 128
|
|
40
|
+
_SEED_ROLES = ("user", "assistant", "system")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reject(reason: str, message: str) -> StatewireReject:
|
|
44
|
+
return StatewireReject(message, payload={"reason": reason})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _capped_seed(seed: Any) -> "list[VoiceManager.SeedMessage]":
|
|
48
|
+
if not isinstance(seed, list):
|
|
49
|
+
raise TypeError(f"get_voice_seed must return a list, got {seed!r}")
|
|
50
|
+
for message in seed:
|
|
51
|
+
if (
|
|
52
|
+
not isinstance(message, dict)
|
|
53
|
+
or message.get("role") not in _SEED_ROLES
|
|
54
|
+
or not isinstance(message.get("text"), str)
|
|
55
|
+
):
|
|
56
|
+
raise TypeError(f"seed messages must be {{role, text}}, got {message!r}")
|
|
57
|
+
return seed[-_SEED_LIMIT:]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class VoiceManager(Tray):
|
|
61
|
+
"""One live voice session: the provider handle, the in-memory turn
|
|
62
|
+
buffer, and its wire projection; ended sessions commit and self-delete."""
|
|
63
|
+
|
|
64
|
+
connection_poll = 0.5
|
|
65
|
+
|
|
66
|
+
clientId = observable(None)
|
|
67
|
+
status = observable("connecting")
|
|
68
|
+
answer = observable(None)
|
|
69
|
+
turns = observable([])
|
|
70
|
+
|
|
71
|
+
state = observable(
|
|
72
|
+
{
|
|
73
|
+
"clientId": clientId,
|
|
74
|
+
"status": status,
|
|
75
|
+
"answer": answer,
|
|
76
|
+
"turns": turns,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
81
|
+
super().__init__(**kwargs)
|
|
82
|
+
self._provider: "VoiceProvider | None" = None
|
|
83
|
+
self._turns: list[dict[str, Any]] = []
|
|
84
|
+
self._ended = False
|
|
85
|
+
self._watcher: "asyncio.Task[Any] | None" = None
|
|
86
|
+
|
|
87
|
+
def items(self):
|
|
88
|
+
yield from ()
|
|
89
|
+
|
|
90
|
+
class SeedMessage(TypedDict):
|
|
91
|
+
"""One transcript message handed to the provider as session history."""
|
|
92
|
+
|
|
93
|
+
role: Literal["user", "assistant", "system"]
|
|
94
|
+
text: str
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ToolRequest:
|
|
98
|
+
call_id: str
|
|
99
|
+
name: str
|
|
100
|
+
arguments: str
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class SessionContext:
|
|
104
|
+
"""The provider factory's per-session context."""
|
|
105
|
+
|
|
106
|
+
client_id: str
|
|
107
|
+
|
|
108
|
+
class Events(Protocol):
|
|
109
|
+
"""The sink a provider drives; the subsystem hands it to ``start``."""
|
|
110
|
+
|
|
111
|
+
def turn_started(self, turn_id: str, role: Literal["user", "assistant"]) -> None: ...
|
|
112
|
+
|
|
113
|
+
def turn_delta(self, turn_id: str, text: str) -> None: ...
|
|
114
|
+
|
|
115
|
+
def turn_done(self, turn_id: str, text: str) -> None: ...
|
|
116
|
+
|
|
117
|
+
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None: ...
|
|
118
|
+
|
|
119
|
+
def rollover_approaching(self, rollover_id: str, deadline: float) -> None: ...
|
|
120
|
+
|
|
121
|
+
def closed(self, reason: str) -> None: ...
|
|
122
|
+
|
|
123
|
+
class Host(Protocol):
|
|
124
|
+
"""The host seam: the thread transcript in, committed turns out."""
|
|
125
|
+
|
|
126
|
+
async def get_voice_seed(self) -> "list[VoiceManager.SeedMessage]":
|
|
127
|
+
"""The transcript as ``{role, text}`` seed messages, tool/system noise skipped."""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
async def commit_voice_turns(self, turns: list[dict[str, Any]]) -> None:
|
|
131
|
+
"""Write completed ``{id, role, text}`` turns to the thread's durable transcript."""
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
# ─── Session lifecycle ──────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async def _begin(
|
|
137
|
+
self, offer: str, seed: "list[VoiceManager.SeedMessage]", caller: Any,
|
|
138
|
+
applied: Callable[[], None],
|
|
139
|
+
) -> Any:
|
|
140
|
+
self.clientId.value = caller.client_id
|
|
141
|
+
try:
|
|
142
|
+
provider = await self.handler(
|
|
143
|
+
VoiceManager.SessionContext(client_id=caller.client_id)
|
|
144
|
+
)
|
|
145
|
+
answer = await provider.start(offer, seed=seed, events=self)
|
|
146
|
+
if not isinstance(answer, str) or answer == "":
|
|
147
|
+
raise TypeError(f"provider.start must return an SDP answer, got {answer!r}")
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
self._ended = True
|
|
150
|
+
self.delete()
|
|
151
|
+
raise _reject("voice-unavailable", f"voice session failed to start: {exc}")
|
|
152
|
+
self._provider = provider
|
|
153
|
+
self.answer.value = answer
|
|
154
|
+
self.status.value = "live"
|
|
155
|
+
applied()
|
|
156
|
+
self._watcher = self._spawn(self._watch(caller))
|
|
157
|
+
return {"answer": answer}
|
|
158
|
+
|
|
159
|
+
async def _watch(self, caller: Any) -> None:
|
|
160
|
+
while caller.is_connected:
|
|
161
|
+
await asyncio.sleep(self.connection_poll)
|
|
162
|
+
await self._background_end("connection-drop")
|
|
163
|
+
|
|
164
|
+
async def _background_end(self, reason: str) -> None:
|
|
165
|
+
try:
|
|
166
|
+
await self._end(reason)
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.exception("voice session %r failed to end cleanly", self.id)
|
|
169
|
+
|
|
170
|
+
async def _end(self, reason: str) -> None:
|
|
171
|
+
if self._ended:
|
|
172
|
+
return
|
|
173
|
+
self._ended = True
|
|
174
|
+
watcher, self._watcher = self._watcher, None
|
|
175
|
+
if watcher is not None and watcher is not asyncio.current_task():
|
|
176
|
+
watcher.cancel()
|
|
177
|
+
provider, self._provider = self._provider, None
|
|
178
|
+
if provider is not None:
|
|
179
|
+
try:
|
|
180
|
+
await provider.close()
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.exception("voice provider close failed for session %r", self.id)
|
|
183
|
+
done = [
|
|
184
|
+
{"id": t["id"], "role": t["role"], "text": t["text"]}
|
|
185
|
+
for t in self._turns
|
|
186
|
+
if t["done"]
|
|
187
|
+
]
|
|
188
|
+
self._turns = []
|
|
189
|
+
self.turns.value = []
|
|
190
|
+
self.status.value = "closed"
|
|
191
|
+
try:
|
|
192
|
+
if done:
|
|
193
|
+
await self.handler.__self__.commit_voice_turns(done)
|
|
194
|
+
finally:
|
|
195
|
+
self.delete()
|
|
196
|
+
|
|
197
|
+
# ─── Events sink ────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def _turn(self, turn_id: str) -> dict[str, Any]:
|
|
200
|
+
for turn in self._turns:
|
|
201
|
+
if turn["id"] == turn_id:
|
|
202
|
+
return turn
|
|
203
|
+
raise ValueError(f"turn {turn_id!r} was never started")
|
|
204
|
+
|
|
205
|
+
def _project_turns(self) -> None:
|
|
206
|
+
self.turns.value = [dict(turn) for turn in self._turns]
|
|
207
|
+
|
|
208
|
+
def turn_started(self, turn_id: str, role: str) -> None:
|
|
209
|
+
if self._ended:
|
|
210
|
+
return
|
|
211
|
+
if any(turn["id"] == turn_id for turn in self._turns):
|
|
212
|
+
raise ValueError(f"turn {turn_id!r} already started")
|
|
213
|
+
self._turns.append({"id": turn_id, "role": role, "text": "", "done": False})
|
|
214
|
+
self._project_turns()
|
|
215
|
+
|
|
216
|
+
def turn_delta(self, turn_id: str, text: str) -> None:
|
|
217
|
+
if self._ended:
|
|
218
|
+
return
|
|
219
|
+
self._turn(turn_id)["text"] += text
|
|
220
|
+
self._project_turns()
|
|
221
|
+
|
|
222
|
+
def turn_done(self, turn_id: str, text: str) -> None:
|
|
223
|
+
if self._ended:
|
|
224
|
+
return
|
|
225
|
+
turn = self._turn(turn_id)
|
|
226
|
+
turn["text"] = text
|
|
227
|
+
turn["done"] = True
|
|
228
|
+
self._project_turns()
|
|
229
|
+
|
|
230
|
+
def tool_requested(self, request: "VoiceManager.ToolRequest") -> None:
|
|
231
|
+
logger.info("voice session %r tool request ignored: %r", self.id, request)
|
|
232
|
+
|
|
233
|
+
def rollover_approaching(self, rollover_id: str, deadline: float) -> None:
|
|
234
|
+
logger.info(
|
|
235
|
+
"voice session %r rollover %r ignored (deadline %s)",
|
|
236
|
+
self.id, rollover_id, deadline,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def closed(self, reason: str) -> None:
|
|
240
|
+
if self._ended:
|
|
241
|
+
return
|
|
242
|
+
self._spawn(self._background_end(reason))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class VoiceProvider(Protocol):
|
|
246
|
+
"""The vendor seam: one live voice session's transport and control plane."""
|
|
247
|
+
|
|
248
|
+
async def start(
|
|
249
|
+
self, offer: str, *,
|
|
250
|
+
seed: "list[VoiceManager.SeedMessage]",
|
|
251
|
+
events: "VoiceManager.Events",
|
|
252
|
+
) -> str:
|
|
253
|
+
"""Open the session from an SDP offer and return the SDP answer; events flow into the sink."""
|
|
254
|
+
...
|
|
255
|
+
|
|
256
|
+
async def append_context(
|
|
257
|
+
self, text: str, *, channel: Literal["speakable", "commentary", "developer"]
|
|
258
|
+
) -> None: ...
|
|
259
|
+
|
|
260
|
+
async def return_tool_result(self, call_id: str, output: str) -> None: ...
|
|
261
|
+
|
|
262
|
+
async def select_rollover(
|
|
263
|
+
self, rollover_id: str, seed: "list[VoiceManager.SeedMessage]"
|
|
264
|
+
) -> None: ...
|
|
265
|
+
|
|
266
|
+
async def close(self) -> None: ...
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class VoiceManagerNamespace(TrayMapNamespace):
|
|
270
|
+
"""The subsystem's command surface: at most one live session, its tray
|
|
271
|
+
mounted as ``state["voice"]``."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, api: Any, spec: TrayDescriptor) -> None:
|
|
274
|
+
super().__init__(api, spec)
|
|
275
|
+
for name in ("get_voice_seed", "commit_voice_turns"):
|
|
276
|
+
if not callable(getattr(api, name, None)):
|
|
277
|
+
raise TypeError(
|
|
278
|
+
f"{type(api).__name__} must implement {name} "
|
|
279
|
+
"(VoiceManager.Host) to host @voice_manager"
|
|
280
|
+
)
|
|
281
|
+
self._session: VoiceManager | None = None
|
|
282
|
+
self._starting = False
|
|
283
|
+
api.state["voice"] = None
|
|
284
|
+
|
|
285
|
+
def live(self) -> VoiceManager | None:
|
|
286
|
+
"""The live session's tray; None at rest."""
|
|
287
|
+
session = self._session
|
|
288
|
+
return session if session is not None and not session._ended else None
|
|
289
|
+
|
|
290
|
+
def _mount(self, tray: VoiceManager) -> None:
|
|
291
|
+
if tray.storage._restored:
|
|
292
|
+
return # a crash's leftover row: the pump finishes its delete unmounted
|
|
293
|
+
self._session = tray
|
|
294
|
+
self._api.state["voice"] = tray.state
|
|
295
|
+
|
|
296
|
+
def _unmount(self, tray: VoiceManager) -> None:
|
|
297
|
+
if self._session is tray:
|
|
298
|
+
self._session = None
|
|
299
|
+
self._api.state["voice"] = None
|
|
300
|
+
|
|
301
|
+
async def start(self, params: Any, *, ctx: Any) -> Any:
|
|
302
|
+
if not isinstance(params, dict):
|
|
303
|
+
raise _reject("invalid-message", "params must be an object")
|
|
304
|
+
offer = params.get("offer")
|
|
305
|
+
if not isinstance(offer, str) or offer == "":
|
|
306
|
+
raise _reject("invalid-message", "offer must be a non-empty SDP string")
|
|
307
|
+
if self._starting or self.live() is not None:
|
|
308
|
+
raise _reject("voice-active", "a voice session is already live")
|
|
309
|
+
self._starting = True
|
|
310
|
+
try:
|
|
311
|
+
seed = _capped_seed(await self._api.get_voice_seed())
|
|
312
|
+
tray = await self.get_or_create(uuid4().hex)
|
|
313
|
+
assert isinstance(tray, VoiceManager)
|
|
314
|
+
return await tray._begin(offer, seed, ctx.caller, ctx.applied)
|
|
315
|
+
finally:
|
|
316
|
+
self._starting = False
|
|
317
|
+
|
|
318
|
+
async def end(self, *, ctx: Any) -> None:
|
|
319
|
+
session = self.live()
|
|
320
|
+
if session is None:
|
|
321
|
+
raise _reject("no-voice", "no voice session is live")
|
|
322
|
+
if session.clientId.value != ctx.caller.client_id:
|
|
323
|
+
raise _reject("not-voice-owner", "voice/end must come from the session owner")
|
|
324
|
+
await session._end("client")
|
|
325
|
+
ctx.applied()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class VoiceManagerDescriptor(TrayDescriptor):
|
|
329
|
+
namespace_cls = VoiceManagerNamespace
|
|
330
|
+
|
|
331
|
+
def __init__(self, handler: Any) -> None:
|
|
332
|
+
super().__init__(VoiceManager, handler, always_open=False)
|
|
333
|
+
|
|
334
|
+
async def mount(api: Any, id: str, tray: VoiceManager):
|
|
335
|
+
ns: VoiceManagerNamespace = getattr(api, self.name)
|
|
336
|
+
ns._mount(tray)
|
|
337
|
+
yield
|
|
338
|
+
ns._unmount(tray)
|
|
339
|
+
|
|
340
|
+
self._on_activate.append(mount)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def voice_manager() -> Callable[[Callable[..., Any]], VoiceManagerDescriptor]:
|
|
344
|
+
"""Declare the voice subsystem on a Statewire host: the decorated method
|
|
345
|
+
is the per-session provider factory and ``self.<name>`` is the command
|
|
346
|
+
surface."""
|
|
347
|
+
|
|
348
|
+
def decorate(fn: Callable[..., Any]) -> VoiceManagerDescriptor:
|
|
349
|
+
if inspect.iscoroutinefunction(fn):
|
|
350
|
+
return VoiceManagerDescriptor(fn)
|
|
351
|
+
|
|
352
|
+
@functools.wraps(fn)
|
|
353
|
+
async def factory(self: Any, ctx: "VoiceManager.SessionContext") -> Any:
|
|
354
|
+
return fn(self, ctx)
|
|
355
|
+
|
|
356
|
+
return VoiceManagerDescriptor(factory)
|
|
357
|
+
|
|
358
|
+
return decorate
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _voice_of(host: Any) -> VoiceManagerNamespace | None:
|
|
362
|
+
for cls in type(host).__mro__:
|
|
363
|
+
for name, member in vars(cls).items():
|
|
364
|
+
if isinstance(member, VoiceManagerDescriptor):
|
|
365
|
+
return getattr(host, name)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def start_voice(host: Any, params: Any, *, ctx: Any) -> Any:
|
|
370
|
+
"""The ``voice/start`` command body: routes to the host's declared voice manager."""
|
|
371
|
+
namespace = _voice_of(host)
|
|
372
|
+
if namespace is None:
|
|
373
|
+
raise _reject("no-voice", "this host declares no voice manager")
|
|
374
|
+
return await namespace.start(params, ctx=ctx)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def end_voice(host: Any, *, ctx: Any) -> None:
|
|
378
|
+
"""The ``voice/end`` command body: routes to the host's declared voice manager."""
|
|
379
|
+
namespace = _voice_of(host)
|
|
380
|
+
if namespace is None:
|
|
381
|
+
raise _reject("no-voice", "this host declares no voice manager")
|
|
382
|
+
return await namespace.end(ctx=ctx)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
harness_sdk/__init__.py,sha256=N9gOUKqe0_eM7cIA9sfA_lH1FBw1MN8U-zerwGAPVHE,1094
|
|
2
|
+
harness_sdk/checkpoints.py,sha256=L02l1IyLDj2b-GD7_HGrck7uenALeI4kg8yJSzoNVmc,25999
|
|
3
|
+
harness_sdk/deepagents.py,sha256=d7Vb1MhwU-UKSvsvuAKEEROxXf8mukEzqfEfCIlcxM4,10915
|
|
4
|
+
harness_sdk/fenced_postgres.py,sha256=hbbrMR1WaqrHTNgd4wFrXfRrzQFGnyim-c8nZXgwWp4,13235
|
|
5
|
+
harness_sdk/linear_thread.py,sha256=laDRXKLFsMlyWS4snqNAM6t38wO26gevLWXRNivhHSM,1810
|
|
6
|
+
harness_sdk/run_manager.py,sha256=CbY_4dpFeqGx3kJNI1ffsu3uIvzVc-Tc1UB6Nr9SiHY,77654
|
|
7
|
+
harness_sdk/voice.py,sha256=LbFO8lpd3jzPuaj2RuxEHckP5TiVjKpQeMsxxaefDPg,14081
|
|
8
|
+
harness_sdk_python-0.27.0.dist-info/METADATA,sha256=sA94429xkLoOYG23eysErRAPt00YaQemCcfnIgTPuYM,2200
|
|
9
|
+
harness_sdk_python-0.27.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
harness_sdk_python-0.27.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
harness_sdk/__init__.py,sha256=lHRu0sZVppfPjWawxEjXrcHIVSHCkdYQjCtIl5D2wmk,758
|
|
2
|
-
harness_sdk/checkpoints.py,sha256=L02l1IyLDj2b-GD7_HGrck7uenALeI4kg8yJSzoNVmc,25999
|
|
3
|
-
harness_sdk/fenced_postgres.py,sha256=hbbrMR1WaqrHTNgd4wFrXfRrzQFGnyim-c8nZXgwWp4,13235
|
|
4
|
-
harness_sdk/linear_thread.py,sha256=laDRXKLFsMlyWS4snqNAM6t38wO26gevLWXRNivhHSM,1810
|
|
5
|
-
harness_sdk/run_manager.py,sha256=xL6TsXJk_3SVyA5oNuGlPelDmCqCRmL-bWW5wsfiIBY,77028
|
|
6
|
-
harness_sdk_python-0.25.2.dist-info/METADATA,sha256=zHAX5aTkYpynVaYAyhUfnapa_elP8vEonSz2bMXyRoA,2200
|
|
7
|
-
harness_sdk_python-0.25.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
-
harness_sdk_python-0.25.2.dist-info/RECORD,,
|
|
File without changes
|