statewire 0.8.2__tar.gz → 0.8.4__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.
- {statewire-0.8.2 → statewire-0.8.4}/PKG-INFO +4 -2
- {statewire-0.8.2 → statewire-0.8.4}/pyproject.toml +3 -2
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/api.py +38 -17
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/client.py +24 -8
- statewire-0.8.4/src/statewire/langgraph.py +343 -0
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/ops.py +50 -14
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/state.py +246 -47
- statewire-0.8.4/tests/test_date_registry.py +238 -0
- statewire-0.8.4/tests/test_langgraph.py +420 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_meta.py +11 -3
- statewire-0.8.4/tests/test_ops.py +50 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_state_proxy.py +1 -2
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_stream.py +28 -0
- statewire-0.8.2/src/statewire/langgraph.py +0 -175
- statewire-0.8.2/tests/test_langgraph.py +0 -142
- {statewire-0.8.2 → statewire-0.8.4}/.gitignore +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/README.md +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/examples/__init__.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/examples/demo_app.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/__init__.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/metrics.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/src/statewire/testing.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/client_helpers.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/statewire_helpers.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_authorize.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_client.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_client_ws.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_commands.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_context.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_frames.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_idle.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_lifespan.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_observer.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_route_auth.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_start_session.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_testing.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_writer_lease.py +0 -0
- {statewire-0.8.2 → statewire-0.8.4}/tests/test_ws.py +0 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: statewire
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.4
|
|
4
4
|
Summary: Replicate one JSON object over an SSE op stream + a command endpoint
|
|
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: fastapi>=0.115
|
|
9
9
|
Requires-Dist: httpx>=0.27
|
|
10
|
-
Requires-Dist: pinned<0.
|
|
10
|
+
Requires-Dist: pinned<0.8,>=0.7.0
|
|
11
11
|
Provides-Extra: langgraph
|
|
12
12
|
Requires-Dist: langchain-core>=0.3; extra == 'langgraph'
|
|
13
|
+
Provides-Extra: orjson
|
|
14
|
+
Requires-Dist: orjson>=3.9; extra == 'orjson'
|
|
13
15
|
Description-Content-Type: text/markdown
|
|
14
16
|
|
|
15
17
|
# statewire
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "statewire"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.4"
|
|
4
4
|
description = "Replicate one JSON object over an SSE op stream + a command endpoint"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.12,<4.0"
|
|
8
8
|
dependencies = [
|
|
9
|
-
"pinned>=0.
|
|
9
|
+
"pinned>=0.7.0,<0.8",
|
|
10
10
|
"fastapi>=0.115",
|
|
11
11
|
"httpx>=0.27",
|
|
12
12
|
]
|
|
13
13
|
|
|
14
14
|
[project.optional-dependencies]
|
|
15
15
|
langgraph = ["langchain-core>=0.3"]
|
|
16
|
+
orjson = ["orjson>=3.9"]
|
|
16
17
|
|
|
17
18
|
[project.urls]
|
|
18
19
|
Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
@@ -130,7 +130,15 @@ from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
|
130
130
|
from starlette.websockets import WebSocketDisconnect
|
|
131
131
|
|
|
132
132
|
from . import metrics, ops
|
|
133
|
-
from .state import
|
|
133
|
+
from .state import (
|
|
134
|
+
StateProxy,
|
|
135
|
+
_date_meta,
|
|
136
|
+
_decode_meta,
|
|
137
|
+
_frozen,
|
|
138
|
+
_frozen_encoded,
|
|
139
|
+
_track_dates,
|
|
140
|
+
plain,
|
|
141
|
+
)
|
|
134
142
|
|
|
135
143
|
if sys.version_info < (3, 12):
|
|
136
144
|
raise RuntimeError("statewire requires Python 3.12+ (eager task execution)")
|
|
@@ -258,7 +266,7 @@ def _parse_frame(frame: Any, *, first: bool) -> _Frame:
|
|
|
258
266
|
|
|
259
267
|
|
|
260
268
|
def _payload_fields(value: Any) -> dict[str, Any]:
|
|
261
|
-
payload, meta =
|
|
269
|
+
payload, meta = _frozen_encoded(value)
|
|
262
270
|
return {"payload": payload, **({"meta": meta} if meta else {})}
|
|
263
271
|
|
|
264
272
|
|
|
@@ -266,10 +274,11 @@ def _ws_auth_rejected(exc: AuthRejected) -> AuthRejected:
|
|
|
266
274
|
fin: dict[str, Any] = {"reason": "unauthorized", "message": exc.message}
|
|
267
275
|
if exc.payload is not None:
|
|
268
276
|
fin.update(_payload_fields(exc.payload))
|
|
277
|
+
# Pinned serializes this payload itself: hand it wire values, not datetimes.
|
|
269
278
|
return AuthRejected(
|
|
270
279
|
exc.message,
|
|
271
280
|
status_code=exc.status_code,
|
|
272
|
-
payload={"fin": fin},
|
|
281
|
+
payload={"fin": _load_json(ops.encode_envelope(fin))},
|
|
273
282
|
)
|
|
274
283
|
|
|
275
284
|
|
|
@@ -670,6 +679,9 @@ class Statewire(PinnedAPI):
|
|
|
670
679
|
def __init__(self, ctx: HostableContext) -> None:
|
|
671
680
|
super().__init__(ctx)
|
|
672
681
|
self._state: Any = _UNSET
|
|
682
|
+
self._state_version = 0
|
|
683
|
+
self._date_paths: set[tuple[str | int, ...]] = set()
|
|
684
|
+
self._snapshot_cache: tuple[int, bytes] | None = None
|
|
673
685
|
self._pending_ops: list[ops.Op] = []
|
|
674
686
|
self._dirty_acks: set[str] = set()
|
|
675
687
|
self._pending_res: dict[str, list[dict[str, Any]]] = {}
|
|
@@ -747,14 +759,15 @@ class Statewire(PinnedAPI):
|
|
|
747
759
|
|
|
748
760
|
@state.setter
|
|
749
761
|
def state(self, value: Any) -> None:
|
|
750
|
-
|
|
751
|
-
if not isinstance(
|
|
762
|
+
stored, meta = _frozen_encoded(plain(value), lenient=self.strip_invalid_types)
|
|
763
|
+
if not isinstance(stored, dict):
|
|
752
764
|
raise TypeError("state must be a dict")
|
|
753
|
-
self._state =
|
|
754
|
-
|
|
755
|
-
self._statewire_record(ops.replace_op([], wire, meta))
|
|
765
|
+
self._state = stored
|
|
766
|
+
self._statewire_record(ops.replace_op([], stored, meta))
|
|
756
767
|
|
|
757
768
|
def _statewire_record(self, op: ops.Op) -> None:
|
|
769
|
+
self._state_version += 1
|
|
770
|
+
_track_dates(self._date_paths, self._state, op)
|
|
758
771
|
self._pending_ops.append(op)
|
|
759
772
|
self._schedule_flush()
|
|
760
773
|
|
|
@@ -837,8 +850,6 @@ class Statewire(PinnedAPI):
|
|
|
837
850
|
for seq in (cmd_applied.get(key, []) if key is not None else [])
|
|
838
851
|
]
|
|
839
852
|
envelope: dict[str, Any] = {}
|
|
840
|
-
if entries:
|
|
841
|
-
envelope["ops"] = entries
|
|
842
853
|
if key is not None and key in res:
|
|
843
854
|
envelope["res"] = res[key] # legacy: envelope res dual-emit
|
|
844
855
|
if key is not None and key in acks:
|
|
@@ -854,10 +865,12 @@ class Statewire(PinnedAPI):
|
|
|
854
865
|
envelope["cmd"] = applied_entries
|
|
855
866
|
if idle_edge:
|
|
856
867
|
envelope["idle"] = level
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
868
|
+
msgs.append(ops.ops_envelope(entries, envelope))
|
|
869
|
+
else:
|
|
870
|
+
if applied_entries:
|
|
871
|
+
msgs.append(ops.encode_envelope({"cmd": applied_entries}))
|
|
872
|
+
if envelope:
|
|
873
|
+
msgs.append(ops.encode_envelope(envelope))
|
|
861
874
|
verdicts = cmd_verdicts.get(key, []) if key is not None else []
|
|
862
875
|
if verdicts:
|
|
863
876
|
msgs.append(ops.encode_envelope({"cmd": verdicts}))
|
|
@@ -1019,7 +1032,6 @@ class Statewire(PinnedAPI):
|
|
|
1019
1032
|
q: asyncio.Queue[Any] = asyncio.Queue(maxsize=self.stream_queue_size)
|
|
1020
1033
|
self._subscribers[q] = client_id
|
|
1021
1034
|
self._gc_clients()
|
|
1022
|
-
wire_state, state_meta = _encoded(self._state)
|
|
1023
1035
|
syn: dict[str, Any] = {
|
|
1024
1036
|
"seq": client.admitted_through(),
|
|
1025
1037
|
"ctxSeq": client.ctx_seq,
|
|
@@ -1029,8 +1041,17 @@ class Statewire(PinnedAPI):
|
|
|
1029
1041
|
}
|
|
1030
1042
|
if self._idle_wire:
|
|
1031
1043
|
syn["idle"] = True
|
|
1032
|
-
|
|
1033
|
-
|
|
1044
|
+
if (
|
|
1045
|
+
self._snapshot_cache is None
|
|
1046
|
+
or self._snapshot_cache[0] != self._state_version
|
|
1047
|
+
):
|
|
1048
|
+
op = ops.replace_op(
|
|
1049
|
+
[], self._state, _date_meta(self._state, self._date_paths)
|
|
1050
|
+
)
|
|
1051
|
+
self._snapshot_cache = (self._state_version, ops.ops_envelope([op])[:-1])
|
|
1052
|
+
# splice the cached b'{"ops":[...]' fragment with the per-attach syn
|
|
1053
|
+
snapshot = (
|
|
1054
|
+
self._snapshot_cache[1] + b"," + ops.encode_envelope({"syn": syn})[1:]
|
|
1034
1055
|
)
|
|
1035
1056
|
return q, snapshot
|
|
1036
1057
|
|
|
@@ -25,7 +25,7 @@ from urllib.parse import quote, urlencode
|
|
|
25
25
|
|
|
26
26
|
import httpx
|
|
27
27
|
|
|
28
|
-
from .
|
|
28
|
+
from .ops import _iso
|
|
29
29
|
|
|
30
30
|
logger = logging.getLogger("statewire.client")
|
|
31
31
|
|
|
@@ -50,14 +50,20 @@ def _describe(failure: dict[str, Any]) -> str:
|
|
|
50
50
|
match fate:
|
|
51
51
|
case "rejected":
|
|
52
52
|
code = failure.get("code")
|
|
53
|
-
detail = (f" ({code})" if code else "") + (
|
|
53
|
+
detail = (f" ({code})" if code else "") + (
|
|
54
|
+
f": {message}" if message else ""
|
|
55
|
+
)
|
|
54
56
|
return f"statewire: command rejected{detail}"
|
|
55
57
|
case "aborted":
|
|
56
58
|
return f"statewire: command aborted ({cause})"
|
|
57
59
|
case "lost":
|
|
58
60
|
return f"statewire: command lost ({cause})"
|
|
59
61
|
case "failed":
|
|
60
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
f"statewire: command failed: {message}"
|
|
64
|
+
if message
|
|
65
|
+
else "statewire: command failed"
|
|
66
|
+
)
|
|
61
67
|
case "crashed":
|
|
62
68
|
return "statewire: command crashed"
|
|
63
69
|
case "expired":
|
|
@@ -103,7 +109,9 @@ def _revive_value(value: Any, meta: Any) -> Any:
|
|
|
103
109
|
parent, key = node, seg
|
|
104
110
|
if isinstance(seg, str) and isinstance(node, dict) and seg in node:
|
|
105
111
|
node = node[seg]
|
|
106
|
-
elif
|
|
112
|
+
elif (
|
|
113
|
+
isinstance(seg, int) and isinstance(node, list) and 0 <= seg < len(node)
|
|
114
|
+
):
|
|
107
115
|
node = node[seg]
|
|
108
116
|
else:
|
|
109
117
|
raise StatewireProtocolError(f"meta path {path} does not resolve")
|
|
@@ -326,7 +334,9 @@ class SseTransport:
|
|
|
326
334
|
except httpx.HTTPError as exc:
|
|
327
335
|
raise TransportError(str(exc) or "network") from exc
|
|
328
336
|
|
|
329
|
-
async def _envelopes(
|
|
337
|
+
async def _envelopes(
|
|
338
|
+
self, response: httpx.Response
|
|
339
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
330
340
|
buffer = ""
|
|
331
341
|
try:
|
|
332
342
|
async for chunk in response.aiter_text():
|
|
@@ -444,7 +454,9 @@ class WsTransport:
|
|
|
444
454
|
async def _frames(self, sock: Any) -> AsyncIterator[dict[str, Any]]:
|
|
445
455
|
while True:
|
|
446
456
|
try:
|
|
447
|
-
raw = await asyncio.wait_for(
|
|
457
|
+
raw = await asyncio.wait_for(
|
|
458
|
+
sock.recv(), self._timing.heartbeat_timeout
|
|
459
|
+
)
|
|
448
460
|
except TimeoutError as exc:
|
|
449
461
|
raise TransportError("timeout") from exc
|
|
450
462
|
except asyncio.CancelledError:
|
|
@@ -1105,11 +1117,15 @@ class StatewireClient:
|
|
|
1105
1117
|
if outcome == "idle":
|
|
1106
1118
|
self._attempt = 0
|
|
1107
1119
|
self._set_connection({"status": "idle"})
|
|
1108
|
-
while
|
|
1120
|
+
while (
|
|
1121
|
+
self._running and not self._has_work() and not self._reconnect_now
|
|
1122
|
+
):
|
|
1109
1123
|
await self._wait_wake()
|
|
1110
1124
|
continue
|
|
1111
1125
|
self._attempt += 1
|
|
1112
|
-
delay =
|
|
1126
|
+
delay = (
|
|
1127
|
+
0.0 if self._reconnect_now else _backoff(self._timing, self._attempt)
|
|
1128
|
+
)
|
|
1113
1129
|
if self._session_known or self._reconnect_now:
|
|
1114
1130
|
reconnecting: dict[str, Any] = {
|
|
1115
1131
|
"status": "reconnecting",
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import weakref
|
|
2
|
+
from typing import Any, NamedTuple
|
|
3
|
+
|
|
4
|
+
from langchain_core.messages.ai import AIMessageChunk, add_ai_message_chunks
|
|
5
|
+
|
|
6
|
+
from .state import plain
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _message_matches(existing_message: Any, message_dict: dict[str, Any]) -> bool:
|
|
10
|
+
existing_message = plain(existing_message)
|
|
11
|
+
if not isinstance(existing_message, dict):
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
message_id = message_dict.get("id")
|
|
15
|
+
if message_id is not None and existing_message.get("id") == message_id:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
tool_call_id = message_dict.get("tool_call_id")
|
|
19
|
+
return (
|
|
20
|
+
tool_call_id is not None
|
|
21
|
+
and existing_message.get("tool_call_id") == tool_call_id
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_existing_message_index(
|
|
26
|
+
messages: Any, message_dict: dict[str, Any]
|
|
27
|
+
) -> int | None:
|
|
28
|
+
for i, existing_message in enumerate(messages):
|
|
29
|
+
if _message_matches(existing_message, message_dict):
|
|
30
|
+
return i
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_provisional_id(message_id: Any) -> bool:
|
|
35
|
+
return message_id is None or (
|
|
36
|
+
isinstance(message_id, str) and message_id.startswith("lc_")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _open_chunk_tail_index(messages: Any, message_dict: dict[str, Any]) -> int | None:
|
|
41
|
+
"""Providers churn chunk ids mid-generation (lc_run-* vs provider ids);
|
|
42
|
+
an open chunk accumulation at the tail is the same generation."""
|
|
43
|
+
if not len(messages):
|
|
44
|
+
return None
|
|
45
|
+
index = len(messages) - 1
|
|
46
|
+
tail = plain(messages[index])
|
|
47
|
+
if not isinstance(tail, dict) or tail.get("type") != "ai":
|
|
48
|
+
return None
|
|
49
|
+
if "chunk_position" not in tail or tail["chunk_position"] == "last":
|
|
50
|
+
return None
|
|
51
|
+
if _is_provisional_id(tail.get("id")) or _is_provisional_id(message_dict.get("id")):
|
|
52
|
+
return index
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _can_patch_value(current_value: Any, next_value: Any) -> bool:
|
|
57
|
+
# Keep these guards in sync with _patch_child so the later mutation cannot
|
|
58
|
+
# partially apply before discovering an unsupported deletion/truncation.
|
|
59
|
+
current_value = plain(current_value)
|
|
60
|
+
|
|
61
|
+
if current_value == next_value:
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
if isinstance(current_value, dict) and isinstance(next_value, dict):
|
|
65
|
+
current_keys = set(current_value.keys())
|
|
66
|
+
next_keys = set(next_value.keys())
|
|
67
|
+
return current_keys.issubset(next_keys) and all(
|
|
68
|
+
_can_patch_value(current_value[key], next_value[key])
|
|
69
|
+
for key in current_keys
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if isinstance(current_value, list) and isinstance(next_value, list):
|
|
73
|
+
return len(current_value) <= len(next_value) and all(
|
|
74
|
+
_can_patch_value(item, next_value[index])
|
|
75
|
+
for index, item in enumerate(current_value)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _patch_child(parent: Any, key: str | int, next_value: Any) -> None:
|
|
82
|
+
"""Patch through StateProxy containers so writes emit granular ops."""
|
|
83
|
+
current_value = plain(parent[key])
|
|
84
|
+
|
|
85
|
+
if current_value == next_value:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if isinstance(current_value, dict) and isinstance(next_value, dict):
|
|
89
|
+
current_keys = set(current_value.keys())
|
|
90
|
+
next_keys = set(next_value.keys())
|
|
91
|
+
if not current_keys.issubset(next_keys):
|
|
92
|
+
raise ValueError("Cannot represent deleted dictionary keys as ops")
|
|
93
|
+
|
|
94
|
+
target = parent[key]
|
|
95
|
+
for child_key in next_keys:
|
|
96
|
+
if child_key not in current_value:
|
|
97
|
+
target[child_key] = next_value[child_key]
|
|
98
|
+
else:
|
|
99
|
+
_patch_child(target, child_key, next_value[child_key])
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if isinstance(current_value, list) and isinstance(next_value, list):
|
|
103
|
+
if len(next_value) < len(current_value):
|
|
104
|
+
raise ValueError("Cannot represent list truncation as ops")
|
|
105
|
+
|
|
106
|
+
target = parent[key]
|
|
107
|
+
for index, item in enumerate(next_value):
|
|
108
|
+
if index >= len(current_value):
|
|
109
|
+
target.append(item)
|
|
110
|
+
else:
|
|
111
|
+
_patch_child(target, index, item)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
parent[key] = next_value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_CHUNK_FIELDS = frozenset(AIMessageChunk(content="").model_dump())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _OpenChunk(NamedTuple):
|
|
121
|
+
index: int
|
|
122
|
+
chunk: AIMessageChunk
|
|
123
|
+
stored: dict[str, Any]
|
|
124
|
+
churn_id: str | None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_open_chunks: weakref.WeakKeyDictionary[Any, _OpenChunk] = weakref.WeakKeyDictionary()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fast_chunk_merge(
|
|
131
|
+
state: Any, entry: _OpenChunk, message: Any, message_dict: dict[str, Any]
|
|
132
|
+
) -> AIMessageChunk | None:
|
|
133
|
+
"""Merge a streamed chunk into the retained open accumulation in O(chunk);
|
|
134
|
+
None on any mismatch, which falls back to the full scan-and-patch path."""
|
|
135
|
+
messages = plain(state["messages"])
|
|
136
|
+
if entry.index >= len(messages) or messages[entry.index] is not entry.stored:
|
|
137
|
+
return None
|
|
138
|
+
if message_dict.get("chunk_position") == "last":
|
|
139
|
+
return None
|
|
140
|
+
if not _CHUNK_FIELDS.issuperset(message_dict):
|
|
141
|
+
return None
|
|
142
|
+
incoming_id = message_dict.get("id")
|
|
143
|
+
if incoming_id != entry.stored.get("id"):
|
|
144
|
+
# Only an id the transcript scan is known to miss may churn into the tail.
|
|
145
|
+
if incoming_id is not None and incoming_id != entry.churn_id:
|
|
146
|
+
return None
|
|
147
|
+
if entry.index != len(messages) - 1:
|
|
148
|
+
return None
|
|
149
|
+
if not (
|
|
150
|
+
_is_provisional_id(entry.stored.get("id"))
|
|
151
|
+
or _is_provisional_id(incoming_id)
|
|
152
|
+
):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
merged = add_ai_message_chunks(entry.chunk, message)
|
|
156
|
+
|
|
157
|
+
changed: dict[str, Any] = {}
|
|
158
|
+
if message_dict["content"] != "":
|
|
159
|
+
changed["content"] = merged.content
|
|
160
|
+
if message_dict["additional_kwargs"]:
|
|
161
|
+
changed["additional_kwargs"] = merged.additional_kwargs
|
|
162
|
+
if message_dict["response_metadata"]:
|
|
163
|
+
changed["response_metadata"] = merged.response_metadata
|
|
164
|
+
if message_dict["tool_call_chunks"]:
|
|
165
|
+
changed["tool_call_chunks"] = merged.tool_call_chunks
|
|
166
|
+
changed["tool_calls"] = merged.tool_calls
|
|
167
|
+
changed["invalid_tool_calls"] = merged.invalid_tool_calls
|
|
168
|
+
if message_dict["usage_metadata"] is not None:
|
|
169
|
+
changed["usage_metadata"] = merged.usage_metadata
|
|
170
|
+
for key in ("id", "name", "chunk_position"):
|
|
171
|
+
value = getattr(merged, key)
|
|
172
|
+
if entry.stored.get(key) != value:
|
|
173
|
+
changed[key] = value
|
|
174
|
+
|
|
175
|
+
if not all(
|
|
176
|
+
_can_patch_value(entry.stored[key], value) for key, value in changed.items()
|
|
177
|
+
):
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
target = state["messages"][entry.index]
|
|
181
|
+
for key, value in changed.items():
|
|
182
|
+
_patch_child(target, key, value)
|
|
183
|
+
return merged
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _retain_open_chunk(
|
|
187
|
+
state: Any,
|
|
188
|
+
host: Any,
|
|
189
|
+
message_dict: dict[str, Any],
|
|
190
|
+
index: int,
|
|
191
|
+
merged: AIMessageChunk | None,
|
|
192
|
+
churn_id: str | None,
|
|
193
|
+
) -> None:
|
|
194
|
+
messages = plain(state["messages"])
|
|
195
|
+
if index >= len(messages):
|
|
196
|
+
return
|
|
197
|
+
stored = messages[index]
|
|
198
|
+
chunk = (
|
|
199
|
+
merged
|
|
200
|
+
if merged is not None
|
|
201
|
+
else AIMessageChunk(**{**message_dict, "type": "AIMessageChunk"})
|
|
202
|
+
)
|
|
203
|
+
if (
|
|
204
|
+
not isinstance(stored, dict)
|
|
205
|
+
or stored.get("id") != chunk.id
|
|
206
|
+
or stored.get("chunk_position", "last") == "last"
|
|
207
|
+
):
|
|
208
|
+
return
|
|
209
|
+
_open_chunks[host] = _OpenChunk(index, chunk, stored, churn_id)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _patch_message(messages: Any, index: int, next_message: dict[str, Any]) -> None:
|
|
213
|
+
current_message = plain(messages[index])
|
|
214
|
+
if not isinstance(current_message, dict) or not _can_patch_value(
|
|
215
|
+
current_message, next_message
|
|
216
|
+
):
|
|
217
|
+
messages[index] = next_message
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
_patch_child(messages, index, next_message)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _dump_message(message: Any) -> dict[str, Any]:
|
|
224
|
+
message_dict = message if isinstance(message, dict) else message.model_dump()
|
|
225
|
+
if message_dict.get("type") == "AIMessageChunk":
|
|
226
|
+
message_dict = {**message_dict, "type": "ai"}
|
|
227
|
+
return message_dict
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _patch_message_fields(
|
|
231
|
+
target: Any, current: dict[str, Any], next_message: dict[str, Any]
|
|
232
|
+
) -> None:
|
|
233
|
+
for key in [key for key in current if key not in next_message]:
|
|
234
|
+
del target[key]
|
|
235
|
+
for key, value in next_message.items():
|
|
236
|
+
if key not in current or current[key] != value:
|
|
237
|
+
target[key] = value
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def replace_messages(state: Any, messages: list[Any]) -> None:
|
|
241
|
+
"""Rebaseline ``state["messages"]`` to ``messages``, emitting granular ops."""
|
|
242
|
+
dumped = [_dump_message(message) for message in messages]
|
|
243
|
+
|
|
244
|
+
host = getattr(state, "_host", None)
|
|
245
|
+
if host is not None:
|
|
246
|
+
_open_chunks.pop(host, None)
|
|
247
|
+
|
|
248
|
+
if "messages" not in state:
|
|
249
|
+
state["messages"] = dumped
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
target = state["messages"]
|
|
253
|
+
stored = plain(target)
|
|
254
|
+
stored_len = len(stored)
|
|
255
|
+
|
|
256
|
+
matched = 0
|
|
257
|
+
while (
|
|
258
|
+
matched < stored_len
|
|
259
|
+
and matched < len(dumped)
|
|
260
|
+
and isinstance(stored[matched], dict)
|
|
261
|
+
and stored[matched].get("id") == dumped[matched].get("id")
|
|
262
|
+
):
|
|
263
|
+
_patch_message_fields(target[matched], stored[matched], dumped[matched])
|
|
264
|
+
matched += 1
|
|
265
|
+
|
|
266
|
+
for index in range(matched, min(stored_len, len(dumped))):
|
|
267
|
+
target[index] = dumped[index]
|
|
268
|
+
for index in range(stored_len, len(dumped)):
|
|
269
|
+
target.append(dumped[index])
|
|
270
|
+
for index in range(stored_len - 1, len(dumped) - 1, -1):
|
|
271
|
+
del target[index]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def append_langgraph_event(
|
|
275
|
+
state: Any, _namespace: str, type: str, payload: Any
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Fold a LangGraph stream event ('messages' or 'updates') into the state."""
|
|
278
|
+
if type == "messages":
|
|
279
|
+
if "messages" not in state:
|
|
280
|
+
state["messages"] = []
|
|
281
|
+
|
|
282
|
+
message = payload[0]
|
|
283
|
+
message_dict = message.model_dump()
|
|
284
|
+
|
|
285
|
+
is_ai_message_chunk = message_dict.get("type") == "AIMessageChunk"
|
|
286
|
+
if is_ai_message_chunk:
|
|
287
|
+
message_dict["type"] = "ai"
|
|
288
|
+
|
|
289
|
+
host = getattr(state, "_host", None)
|
|
290
|
+
entry = _open_chunks.get(host) if host is not None else None
|
|
291
|
+
if entry is not None and is_ai_message_chunk:
|
|
292
|
+
merged = _fast_chunk_merge(state, entry, message, message_dict)
|
|
293
|
+
if merged is not None:
|
|
294
|
+
_open_chunks[host] = entry._replace(chunk=merged)
|
|
295
|
+
return
|
|
296
|
+
if host is not None:
|
|
297
|
+
_open_chunks.pop(host, None)
|
|
298
|
+
|
|
299
|
+
merged = None
|
|
300
|
+
scan_index = _find_existing_message_index(state["messages"], message_dict)
|
|
301
|
+
existing_message_index = scan_index
|
|
302
|
+
if existing_message_index is None and is_ai_message_chunk:
|
|
303
|
+
existing_message_index = _open_chunk_tail_index(
|
|
304
|
+
state["messages"], message_dict
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if existing_message_index is not None:
|
|
308
|
+
if is_ai_message_chunk:
|
|
309
|
+
existing_message = plain(state["messages"][existing_message_index])
|
|
310
|
+
merged = add_ai_message_chunks(
|
|
311
|
+
AIMessageChunk(**{**existing_message, "type": "AIMessageChunk"}),
|
|
312
|
+
AIMessageChunk(**{**message_dict, "type": "AIMessageChunk"}),
|
|
313
|
+
)
|
|
314
|
+
new_message_dict = merged.model_dump()
|
|
315
|
+
new_message_dict["type"] = "ai"
|
|
316
|
+
_patch_message(
|
|
317
|
+
state["messages"],
|
|
318
|
+
existing_message_index,
|
|
319
|
+
new_message_dict,
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
state["messages"][existing_message_index] = message_dict
|
|
323
|
+
else:
|
|
324
|
+
state["messages"].append(message_dict)
|
|
325
|
+
|
|
326
|
+
if host is not None and is_ai_message_chunk:
|
|
327
|
+
index = (
|
|
328
|
+
existing_message_index
|
|
329
|
+
if existing_message_index is not None
|
|
330
|
+
else len(plain(state["messages"])) - 1
|
|
331
|
+
)
|
|
332
|
+
churn_id = message_dict.get("id") if scan_index is None else None
|
|
333
|
+
_retain_open_chunk(state, host, message_dict, index, merged, churn_id)
|
|
334
|
+
|
|
335
|
+
elif type == "updates":
|
|
336
|
+
for _node_name, channels in payload.items():
|
|
337
|
+
if not isinstance(channels, dict):
|
|
338
|
+
continue
|
|
339
|
+
for channel_name, channel_value in channels.items():
|
|
340
|
+
if channel_name == "messages":
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
state[channel_name] = channel_value
|
|
@@ -125,29 +125,44 @@ silence; 30s of receive silence means the line is dead.
|
|
|
125
125
|
"""
|
|
126
126
|
|
|
127
127
|
import json
|
|
128
|
-
from
|
|
128
|
+
from datetime import datetime, timezone
|
|
129
|
+
from typing import Any, NamedTuple
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
import orjson
|
|
133
|
+
except ImportError:
|
|
134
|
+
orjson = None
|
|
129
135
|
|
|
130
|
-
Op = dict[str, Any]
|
|
131
136
|
Path = list[str | int]
|
|
132
137
|
Meta = list[dict[str, Any]] | None
|
|
133
138
|
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
class Op(NamedTuple):
|
|
141
|
+
"""A recorded state change: kind/path/meta as data for the host's date
|
|
142
|
+
registry, plus its wire bytes, encoded at record time."""
|
|
143
|
+
|
|
144
|
+
kind: str
|
|
145
|
+
path: Path
|
|
146
|
+
meta: Meta
|
|
147
|
+
data: bytes
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _op(kind: str, path: Path, body: dict[str, Any], meta: Meta) -> Op:
|
|
137
151
|
if meta:
|
|
138
|
-
|
|
139
|
-
return
|
|
152
|
+
body["meta"] = meta
|
|
153
|
+
return Op(kind, path, meta or None, encode_envelope(body))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def replace_op(path: Path, value: Any, meta: Meta = None) -> Op:
|
|
157
|
+
return _op("replace", path, {"op": "replace", "path": path, "value": value}, meta)
|
|
140
158
|
|
|
141
159
|
|
|
142
160
|
def add_op(path: Path, value: Any, meta: Meta = None) -> Op:
|
|
143
|
-
|
|
144
|
-
if meta:
|
|
145
|
-
op["meta"] = meta
|
|
146
|
-
return op
|
|
161
|
+
return _op("add", path, {"op": "add", "path": path, "value": value}, meta)
|
|
147
162
|
|
|
148
163
|
|
|
149
164
|
def remove_op(path: Path) -> Op:
|
|
150
|
-
return {"op": "remove", "path": path}
|
|
165
|
+
return _op("remove", path, {"op": "remove", "path": path}, None)
|
|
151
166
|
|
|
152
167
|
|
|
153
168
|
PROTOCOL_VERSION = 1
|
|
@@ -155,12 +170,33 @@ WS_SUBPROTOCOL = "statewire.v1"
|
|
|
155
170
|
VERSION_HEADER = "Statewire-Version"
|
|
156
171
|
|
|
157
172
|
|
|
158
|
-
def
|
|
159
|
-
return
|
|
173
|
+
def _iso(value: datetime) -> str:
|
|
174
|
+
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds")[:-6] + "Z"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _iso_default(value: Any) -> str:
|
|
178
|
+
if not isinstance(value, datetime):
|
|
179
|
+
raise TypeError(f"{type(value).__name__} is not wire-encodable")
|
|
180
|
+
return _iso(value)
|
|
160
181
|
|
|
161
182
|
|
|
162
183
|
def encode_envelope(envelope: dict[str, Any]) -> bytes:
|
|
163
|
-
|
|
184
|
+
if orjson is not None:
|
|
185
|
+
# orjson nullifies non-finite floats instead of raising; _frozen
|
|
186
|
+
# rejects them before any envelope value is built.
|
|
187
|
+
return orjson.dumps(
|
|
188
|
+
envelope, default=_iso_default, option=orjson.OPT_PASSTHROUGH_DATETIME
|
|
189
|
+
)
|
|
190
|
+
return json.dumps(
|
|
191
|
+
envelope, separators=(",", ":"), allow_nan=False, default=_iso_default
|
|
192
|
+
).encode()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def ops_envelope(entries: list[Op], rest: dict[str, Any] | None = None) -> bytes:
|
|
196
|
+
head = b'{"ops":[' + b",".join(op.data for op in entries) + b"]"
|
|
197
|
+
if not rest:
|
|
198
|
+
return head + b"}"
|
|
199
|
+
return head + b"," + encode_envelope(rest)[1:]
|
|
164
200
|
|
|
165
201
|
|
|
166
202
|
def sse_frame(data: bytes) -> bytes:
|