statewire 0.3.0__tar.gz → 0.3.2__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.3.0 → statewire-0.3.2}/PKG-INFO +1 -1
- {statewire-0.3.0 → statewire-0.3.2}/pyproject.toml +1 -1
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/api.py +58 -19
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/assistant_transport.py +167 -17
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_assistant_transport.py +420 -0
- statewire-0.3.2/tests/test_authorize.py +256 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_commands.py +19 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_state_proxy.py +1 -1
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_writer_lease.py +1 -1
- statewire-0.3.0/tests/test_authorize.py +0 -56
- {statewire-0.3.0 → statewire-0.3.2}/.gitignore +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/README.md +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/examples/__init__.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/examples/demo_app.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/__init__.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/assistant_transport_client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/langgraph.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/ops.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/src/statewire/state.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/client_helpers.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/statewire_helpers.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_assistant_transport_client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_assistant_transport_facade.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_client.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_client_ws.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_context.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_langgraph.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_lifespan.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_meta.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_stream.py +0 -0
- {statewire-0.3.0 → statewire-0.3.2}/tests/test_ws.py +0 -0
|
@@ -36,6 +36,16 @@ body over HTTP, as a ``res`` on the stream over WS.
|
|
|
36
36
|
Handlers that declare a keyword-only ``ctx`` parameter receive a
|
|
37
37
|
``StatewireCommandContext``: ``ctx.caller`` is the submitting client's
|
|
38
38
|
handle (holdable across a long run), ``ctx.ack()`` the invocation ack.
|
|
39
|
+
``authorize`` gates every ingress and returns the caller's identity
|
|
40
|
+
(``None`` = anonymous); the server stamps it on the client record per
|
|
41
|
+
attach and per admitted command submission — never client-supplied,
|
|
42
|
+
never on a rejected ingress — and drops it with the record. Handlers
|
|
43
|
+
read it as ``ctx.caller.identity``: the current session owner, live —
|
|
44
|
+
a newer authorized attach re-stamps it, so per-command trust decisions
|
|
45
|
+
read it before the first await. A client id is a within-thread session
|
|
46
|
+
id, not an authorization boundary: ``authorize`` must gate the thread
|
|
47
|
+
itself, since any authorized principal knowing a client id can attach,
|
|
48
|
+
receive the snapshot, and supersede that session.
|
|
39
49
|
|
|
40
50
|
The host also owns a scheduler: ``self.schedule(fn)`` runs ``fn`` at the
|
|
41
51
|
next drain (duplicate schedules coalesce), ``self.drain()`` drains now.
|
|
@@ -161,6 +171,7 @@ class _Client:
|
|
|
161
171
|
self.results: dict[int, tuple[float, dict[str, Any]]] = {}
|
|
162
172
|
self.inflight: dict[int, _Invocation] = {}
|
|
163
173
|
self.context: dict[str, Any] | None = None
|
|
174
|
+
self.identity: Any = None
|
|
164
175
|
|
|
165
176
|
def settle(self, seq: int, response: dict[str, Any] | None = None) -> None:
|
|
166
177
|
full = {"seq": seq, **(response if response is not None else {"type": "accepted"})}
|
|
@@ -207,8 +218,9 @@ class _Client:
|
|
|
207
218
|
class StatewireClientHandle:
|
|
208
219
|
"""Read-only view of the submitting client, reachable as ``ctx.caller``
|
|
209
220
|
in a ``@command`` handler. Holdable across a long run: ``context`` reads
|
|
210
|
-
the client's current stored context live, ``
|
|
211
|
-
|
|
221
|
+
the client's current stored context live, ``identity`` the current
|
|
222
|
+
session owner's identity, and ``is_connected`` reports whether the
|
|
223
|
+
client has an active stream attachment."""
|
|
212
224
|
|
|
213
225
|
def __init__(self, host: "Statewire", client: _Client) -> None:
|
|
214
226
|
self._host = host
|
|
@@ -222,6 +234,13 @@ class StatewireClientHandle:
|
|
|
222
234
|
def context(self) -> dict[str, Any] | None:
|
|
223
235
|
return self._client.context
|
|
224
236
|
|
|
237
|
+
@property
|
|
238
|
+
def identity(self) -> Any:
|
|
239
|
+
"""Current session owner's identity — live, re-stamped by a newer
|
|
240
|
+
authorized attach; per-command trust decisions read it before the
|
|
241
|
+
handler's first await."""
|
|
242
|
+
return self._client.identity
|
|
243
|
+
|
|
225
244
|
@property
|
|
226
245
|
def is_connected(self) -> bool:
|
|
227
246
|
client = self._client
|
|
@@ -391,11 +410,14 @@ class Statewire(PinnedAPI):
|
|
|
391
410
|
f"{cls.__name__}.{fn.__name__} declares @route on reserved "
|
|
392
411
|
f"path {spec.path!r}; it is a Statewire protocol endpoint"
|
|
393
412
|
)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
413
|
+
# Full-MRO scan so plain mixins (no Statewire base) contribute their
|
|
414
|
+
# @command handlers; the most derived registration of a method wins.
|
|
415
|
+
merged: dict[str, str] = {}
|
|
416
|
+
for base in reversed(cls.__mro__):
|
|
417
|
+
for name, fn in vars(base).items():
|
|
418
|
+
method = getattr(fn, "_statewire_command_method", None)
|
|
419
|
+
if method is not None:
|
|
420
|
+
merged[method] = name
|
|
399
421
|
cls._statewire_commands = merged
|
|
400
422
|
lifespan = cls.__dict__.get("lifespan")
|
|
401
423
|
if lifespan is not None and not getattr(lifespan, "_statewire_checked", False):
|
|
@@ -589,14 +611,22 @@ class Statewire(PinnedAPI):
|
|
|
589
611
|
|
|
590
612
|
# ─── GET /stream ────────────────────────────────────────
|
|
591
613
|
|
|
592
|
-
async def authorize(self, request: HTTPConnection) ->
|
|
593
|
-
"""Auth seam: override to gate /stream, /commands and /ws; raise
|
|
614
|
+
async def authorize(self, request: HTTPConnection) -> Any:
|
|
615
|
+
"""Auth seam: override to gate /stream, /commands and /ws; raise
|
|
616
|
+
HTTPException to reject. The return value is the caller's identity —
|
|
617
|
+
any host-chosen object, ``None`` for anonymous — stamped on the client
|
|
618
|
+
record at each attach and each admitted command submission (fresh
|
|
619
|
+
identity wins) and read by handlers via ``ctx.caller.identity``."""
|
|
620
|
+
return None
|
|
594
621
|
|
|
595
|
-
def _register(
|
|
622
|
+
def _register(
|
|
623
|
+
self, client_id: str, identity: Any
|
|
624
|
+
) -> tuple[asyncio.Queue[Any], bytes]:
|
|
596
625
|
client = self._clients.get(client_id)
|
|
597
626
|
last = client.last_seq if client is not None else -1
|
|
598
627
|
if client is None:
|
|
599
628
|
client = self._clients[client_id] = _Client(self, client_id)
|
|
629
|
+
client.identity = identity
|
|
600
630
|
client.touched = time.monotonic()
|
|
601
631
|
self._flush()
|
|
602
632
|
self._evict_streams(
|
|
@@ -650,9 +680,9 @@ class Statewire(PinnedAPI):
|
|
|
650
680
|
|
|
651
681
|
@route.get("/stream")
|
|
652
682
|
async def stream(self, request: Request) -> Response:
|
|
653
|
-
await self.authorize(request)
|
|
683
|
+
identity = await self.authorize(request)
|
|
654
684
|
client_id = _client_id_of(request)
|
|
655
|
-
q, snapshot = self._register(client_id)
|
|
685
|
+
q, snapshot = self._register(client_id, identity)
|
|
656
686
|
return StreamingResponse(
|
|
657
687
|
self._stream_events(q, snapshot),
|
|
658
688
|
media_type="text/event-stream",
|
|
@@ -707,7 +737,7 @@ class Statewire(PinnedAPI):
|
|
|
707
737
|
|
|
708
738
|
@route.post("/commands")
|
|
709
739
|
async def commands(self, request: Request) -> Any:
|
|
710
|
-
await self.authorize(request)
|
|
740
|
+
identity = await self.authorize(request)
|
|
711
741
|
client_id = _client_id_of(request)
|
|
712
742
|
seq = _command_seq_of(request)
|
|
713
743
|
lease = request.headers.get(LEASE_HEADER)
|
|
@@ -716,7 +746,7 @@ class Statewire(PinnedAPI):
|
|
|
716
746
|
body = json.loads(raw)
|
|
717
747
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
718
748
|
raise HTTPException(status_code=400, detail="body must be JSON") from None
|
|
719
|
-
return await self._submit(client_id, seq, body, lease)
|
|
749
|
+
return await self._submit(client_id, seq, body, lease, identity)
|
|
720
750
|
|
|
721
751
|
def _validate_submission(
|
|
722
752
|
self, body: Any
|
|
@@ -785,7 +815,12 @@ class Statewire(PinnedAPI):
|
|
|
785
815
|
return _Prepared(method, handler, params, wants_ctx), None
|
|
786
816
|
|
|
787
817
|
async def _admit(
|
|
788
|
-
self,
|
|
818
|
+
self,
|
|
819
|
+
client_id: str,
|
|
820
|
+
seq: int,
|
|
821
|
+
body: Any,
|
|
822
|
+
lease: str | None,
|
|
823
|
+
identity: Any = _UNSET,
|
|
789
824
|
) -> tuple[str, Any]:
|
|
790
825
|
client = self._clients.get(client_id)
|
|
791
826
|
if client is None:
|
|
@@ -800,6 +835,10 @@ class Statewire(PinnedAPI):
|
|
|
800
835
|
context, members, rejections = self._validate_submission(body)
|
|
801
836
|
if rejections:
|
|
802
837
|
return "rejected", rejections
|
|
838
|
+
# Only an admitted submission may re-stamp; _UNSET keeps the
|
|
839
|
+
# attach-time identity (WS frames, facade dispatch).
|
|
840
|
+
if identity is not _UNSET:
|
|
841
|
+
client.identity = identity
|
|
803
842
|
if context is not _UNSET:
|
|
804
843
|
client.context = context
|
|
805
844
|
invocations = self._start_batch(client, seq, members)
|
|
@@ -811,9 +850,9 @@ class Statewire(PinnedAPI):
|
|
|
811
850
|
return "executed", None
|
|
812
851
|
|
|
813
852
|
async def _submit(
|
|
814
|
-
self, client_id: str, seq: int, body: Any, lease: str | None
|
|
853
|
+
self, client_id: str, seq: int, body: Any, lease: str | None, identity: Any
|
|
815
854
|
) -> Any:
|
|
816
|
-
outcome, detail = await self._admit(client_id, seq, body, lease)
|
|
855
|
+
outcome, detail = await self._admit(client_id, seq, body, lease, identity)
|
|
817
856
|
match outcome:
|
|
818
857
|
case "unknown-client":
|
|
819
858
|
return JSONResponse({"error": "unknown-client"}, status_code=412)
|
|
@@ -967,7 +1006,7 @@ class Statewire(PinnedAPI):
|
|
|
967
1006
|
@route.websocket("/ws")
|
|
968
1007
|
async def ws(self, websocket: WebSocket) -> None:
|
|
969
1008
|
try:
|
|
970
|
-
await self.authorize(websocket)
|
|
1009
|
+
identity = await self.authorize(websocket)
|
|
971
1010
|
except HTTPException as exc:
|
|
972
1011
|
reason = exc.detail if isinstance(exc.detail, str) else "unauthorized"
|
|
973
1012
|
raise WebSocketException(code=1008, reason=reason) from None
|
|
@@ -979,7 +1018,7 @@ class Statewire(PinnedAPI):
|
|
|
979
1018
|
await websocket.accept(
|
|
980
1019
|
subprotocol=ops.WS_SUBPROTOCOL if ops.WS_SUBPROTOCOL in offered else None
|
|
981
1020
|
)
|
|
982
|
-
q, snapshot = self._register(client_id)
|
|
1021
|
+
q, snapshot = self._register(client_id, identity)
|
|
983
1022
|
lease = self._clients[client_id].lease
|
|
984
1023
|
try:
|
|
985
1024
|
await self._ws_pump(websocket, q, client_id, snapshot, lease)
|
|
@@ -12,7 +12,28 @@ context, readable via ``ctx.caller`` in handlers that declare a keyword-only
|
|
|
12
12
|
``ctx`` parameter. Unknown fields below the top level are ignored. All
|
|
13
13
|
commands ``{"type": <name>, ...}`` are submitted as one batch through the
|
|
14
14
|
normal Statewire admission path to the ``@command`` handlers registered under
|
|
15
|
-
those names, each with the whole command object as the single param
|
|
15
|
+
those names, each with the whole command object as the single param — except
|
|
16
|
+
the legacy commands, which are normalized to the canonical ``run/*`` dialect
|
|
17
|
+
(a host-registered handler of the same legacy name always wins and receives
|
|
18
|
+
the command whole):
|
|
19
|
+
|
|
20
|
+
- ``add-message`` becomes ``run/steer`` with ``{"message": <wire message>}``:
|
|
21
|
+
a fresh ``legacy_`` id is synthesized, image parts become
|
|
22
|
+
``{"type": "file", "mediaType": "image/*", "url"}`` parts, and command-level
|
|
23
|
+
anchors are dropped. When the body carries a top-level ``parentId`` and
|
|
24
|
+
``get_message_child_id`` resolves it to a child, the command becomes
|
|
25
|
+
``run/edit`` with ``{"sourceId": <child>, "message": <wire message>}``.
|
|
26
|
+
- ``add-tool-result`` becomes ``run/input`` with ``{"requestId": <resolved>,
|
|
27
|
+
"response": {"output": <result>, "isError"?, "artifact"?}}``; the requestId
|
|
28
|
+
comes from ``get_input_request_id(toolCallId)``, whose default peeks the
|
|
29
|
+
unanswered tool-call request in ``state["inputRequests"]`` (None rejects),
|
|
30
|
+
and a ``modelContent`` field rejects (unsupported in ``run/input``).
|
|
31
|
+
- A body with a ``parentId`` and no commands is the legacy reload: it becomes
|
|
32
|
+
``run/reload`` with ``{"sourceId": <child>}`` via ``get_message_child_id``
|
|
33
|
+
(None rejects).
|
|
34
|
+
|
|
35
|
+
A host without the target ``run/*`` handler rejects the batch via the
|
|
36
|
+
ordinary unknown-command path. The response streams
|
|
16
37
|
state changes as legacy frames until every submitted command settles, then
|
|
17
38
|
EOF; a rejection or crash becomes an error frame followed by EOF. Client
|
|
18
39
|
disconnect before EOF invokes ``on_assistant_transport_disconnect`` (a plain
|
|
@@ -26,9 +47,11 @@ response is 200 with an empty body and ``X-Stream-Status: not_found``. If the
|
|
|
26
47
|
run already completed, the replay carries ``X-Stream-Status: completed``. A
|
|
27
48
|
resume disconnect never invokes the cancel hook.
|
|
28
49
|
|
|
29
|
-
``POST /assistant-transport/api/cancel`` invokes the same disconnect hook
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
``POST /assistant-transport/api/cancel`` invokes the same disconnect hook,
|
|
51
|
+
dispatches the host's registered ``run/stop`` handler (if any; a rejection —
|
|
52
|
+
e.g. wrong-state while idle — is swallowed), and responds
|
|
53
|
+
``{"success": true, "found": <bool>}``; ``found`` is true when a live run
|
|
54
|
+
existed at request time.
|
|
32
55
|
|
|
33
56
|
``POST /assistant-transport/api/status`` responds 200 always:
|
|
34
57
|
``{"isRunning": true, "status": "running"}`` while a run is active,
|
|
@@ -52,6 +75,7 @@ import json
|
|
|
52
75
|
import secrets
|
|
53
76
|
import time
|
|
54
77
|
from typing import Any, AsyncIterator
|
|
78
|
+
from uuid import uuid4
|
|
55
79
|
|
|
56
80
|
import httpx
|
|
57
81
|
from fastapi import FastAPI, HTTPException, Request
|
|
@@ -60,7 +84,7 @@ from starlette.background import BackgroundTask
|
|
|
60
84
|
from starlette.responses import Response, StreamingResponse
|
|
61
85
|
|
|
62
86
|
from .api import _LAGGARD, Statewire
|
|
63
|
-
from .state import _frozen
|
|
87
|
+
from .state import _frozen, plain
|
|
64
88
|
|
|
65
89
|
_STREAM_HEADERS = {
|
|
66
90
|
"Cache-Control": "no-cache, no-store, no-transform",
|
|
@@ -162,6 +186,28 @@ class _RunError(Exception):
|
|
|
162
186
|
pass
|
|
163
187
|
|
|
164
188
|
|
|
189
|
+
def _legacy_wire_message(message: Any) -> dict[str, Any]:
|
|
190
|
+
if not isinstance(message, dict):
|
|
191
|
+
raise _RunError("add-message: message must be an object")
|
|
192
|
+
parts = message.get("parts")
|
|
193
|
+
if not isinstance(parts, list):
|
|
194
|
+
raise _RunError("add-message: message.parts must be an array")
|
|
195
|
+
converted = []
|
|
196
|
+
for part in parts:
|
|
197
|
+
if isinstance(part, dict) and part.get("type") == "image":
|
|
198
|
+
image = part.get("image")
|
|
199
|
+
if not isinstance(image, str):
|
|
200
|
+
raise _RunError("add-message: image part requires a string image field")
|
|
201
|
+
converted.append({"type": "file", "mediaType": "image/*", "url": image})
|
|
202
|
+
else:
|
|
203
|
+
converted.append(part)
|
|
204
|
+
return {
|
|
205
|
+
"id": f"legacy_{uuid4().hex}",
|
|
206
|
+
"role": message.get("role"),
|
|
207
|
+
"parts": converted,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
165
211
|
_Item = tuple[str, Any]
|
|
166
212
|
|
|
167
213
|
|
|
@@ -193,9 +239,38 @@ class AssistantTransport(Statewire):
|
|
|
193
239
|
async def on_assistant_transport_disconnect(self) -> None:
|
|
194
240
|
"""The legacy client went away or asked to stop; treat as a cancel signal."""
|
|
195
241
|
|
|
242
|
+
async def get_input_request_id(self, tool_call_id: str) -> str | None:
|
|
243
|
+
"""Resolve a legacy toolCallId to the pending run/input requestId.
|
|
244
|
+
|
|
245
|
+
The default peeks the RunManager root-state mount:
|
|
246
|
+
``state["inputRequests"]`` holds ``{..., "response": null}`` entries;
|
|
247
|
+
the unanswered tool-call request matching ``tool_call_id`` wins. None
|
|
248
|
+
rejects the legacy ``add-tool-result`` command."""
|
|
249
|
+
state = plain(self.state)
|
|
250
|
+
requests = state.get("inputRequests") if isinstance(state, dict) else None
|
|
251
|
+
if not isinstance(requests, list):
|
|
252
|
+
return None
|
|
253
|
+
for request in requests:
|
|
254
|
+
if (
|
|
255
|
+
isinstance(request, dict)
|
|
256
|
+
and request.get("type") == "tool-call"
|
|
257
|
+
and request.get("toolCallId") == tool_call_id
|
|
258
|
+
and request.get("response") is None
|
|
259
|
+
):
|
|
260
|
+
return request.get("id")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
async def get_message_child_id(self, parent_id: str) -> str | None:
|
|
264
|
+
"""Resolve a message id to its child message id.
|
|
265
|
+
|
|
266
|
+
Anchors the legacy reload (``run/reload``) and edit (``run/edit``)
|
|
267
|
+
translations. None (the default) rejects a legacy reload and sends
|
|
268
|
+
``add-message`` as ``run/steer``."""
|
|
269
|
+
return None
|
|
270
|
+
|
|
196
271
|
@route.post("/assistant-transport/api/chat")
|
|
197
272
|
async def assistant_transport_chat(self, request: Request) -> Response:
|
|
198
|
-
await self.authorize(request)
|
|
273
|
+
identity = await self.authorize(request)
|
|
199
274
|
fmt = _FORMATS[self.assistant_transport_protocol]
|
|
200
275
|
raw = await request.body()
|
|
201
276
|
try:
|
|
@@ -224,14 +299,19 @@ class AssistantTransport(Statewire):
|
|
|
224
299
|
status_code=400, detail=f"invalid context: {exc}"
|
|
225
300
|
) from None
|
|
226
301
|
client_id = "at-" + secrets.token_urlsafe(9)
|
|
227
|
-
q, snapshot = self._register(client_id)
|
|
302
|
+
q, snapshot = self._register(client_id, identity)
|
|
228
303
|
self._clients[client_id].context = context
|
|
229
304
|
lease = self._clients[client_id].lease
|
|
230
305
|
assert lease is not None
|
|
306
|
+
parent_id = body.get("parentId")
|
|
307
|
+
if not isinstance(parent_id, str):
|
|
308
|
+
parent_id = None
|
|
231
309
|
run = _Run(json.loads(snapshot)["ops"][0]["value"])
|
|
232
310
|
self._at_run = run
|
|
233
311
|
self.create_task(
|
|
234
|
-
self._assistant_transport_run(
|
|
312
|
+
self._assistant_transport_run(
|
|
313
|
+
run, q, client_id, lease, commands, parent_id
|
|
314
|
+
)
|
|
235
315
|
)
|
|
236
316
|
return StreamingResponse(
|
|
237
317
|
self._assistant_transport_attach(fmt, run, initial=True),
|
|
@@ -272,11 +352,13 @@ class AssistantTransport(Statewire):
|
|
|
272
352
|
|
|
273
353
|
@route.post("/assistant-transport/api/cancel")
|
|
274
354
|
async def assistant_transport_cancel(self, request: Request) -> dict[str, Any]:
|
|
275
|
-
await self.authorize(request)
|
|
355
|
+
identity = await self.authorize(request)
|
|
276
356
|
run = self._at_run
|
|
277
357
|
found = run is not None and not run.done
|
|
278
358
|
if found:
|
|
279
359
|
await self.on_assistant_transport_disconnect()
|
|
360
|
+
if "run/stop" in type(self)._statewire_commands:
|
|
361
|
+
await self._assistant_transport_dispatch("run/stop", identity)
|
|
280
362
|
return {"success": True, "found": found}
|
|
281
363
|
|
|
282
364
|
@route.post("/assistant-transport/api/status")
|
|
@@ -299,16 +381,22 @@ class AssistantTransport(Statewire):
|
|
|
299
381
|
}
|
|
300
382
|
|
|
301
383
|
async def _assistant_transport_submit(
|
|
302
|
-
self,
|
|
384
|
+
self,
|
|
385
|
+
client_id: str,
|
|
386
|
+
lease: str,
|
|
387
|
+
commands: list[dict[str, Any]],
|
|
388
|
+
parent_id: str | None,
|
|
303
389
|
) -> None:
|
|
304
390
|
if not commands:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
391
|
+
if parent_id is None:
|
|
392
|
+
return
|
|
393
|
+
members = [await self._legacy_reload(parent_id)]
|
|
394
|
+
else:
|
|
395
|
+
members = [
|
|
396
|
+
await self._assistant_transport_member(command, parent_id)
|
|
309
397
|
for command in commands
|
|
310
398
|
]
|
|
311
|
-
}
|
|
399
|
+
body = {"commands": members}
|
|
312
400
|
outcome, detail = await self._admit(client_id, 1, body, lease)
|
|
313
401
|
if outcome == "rejected":
|
|
314
402
|
raise _RunError(
|
|
@@ -320,6 +408,67 @@ class AssistantTransport(Statewire):
|
|
|
320
408
|
if outcome != "executed":
|
|
321
409
|
raise _RunError(f"command submission failed: {outcome}")
|
|
322
410
|
|
|
411
|
+
async def _assistant_transport_member(
|
|
412
|
+
self, command: dict[str, Any], parent_id: str | None
|
|
413
|
+
) -> dict[str, Any]:
|
|
414
|
+
kind = command["type"]
|
|
415
|
+
registered = type(self)._statewire_commands
|
|
416
|
+
if kind == "add-message" and "add-message" not in registered:
|
|
417
|
+
message = _legacy_wire_message(command.get("message"))
|
|
418
|
+
source_id = (
|
|
419
|
+
await self.get_message_child_id(parent_id)
|
|
420
|
+
if parent_id is not None
|
|
421
|
+
else None
|
|
422
|
+
)
|
|
423
|
+
if source_id is not None:
|
|
424
|
+
return {
|
|
425
|
+
"method": "run/edit",
|
|
426
|
+
"params": [{"sourceId": source_id, "message": message}],
|
|
427
|
+
}
|
|
428
|
+
return {"method": "run/steer", "params": [{"message": message}]}
|
|
429
|
+
if kind == "add-tool-result" and "add-tool-result" not in registered:
|
|
430
|
+
return {
|
|
431
|
+
"method": "run/input",
|
|
432
|
+
"params": [await self._legacy_input(command)],
|
|
433
|
+
}
|
|
434
|
+
return {"method": kind, "params": [command]}
|
|
435
|
+
|
|
436
|
+
async def _legacy_input(self, command: dict[str, Any]) -> dict[str, Any]:
|
|
437
|
+
if command.get("modelContent") is not None:
|
|
438
|
+
raise _RunError("add-tool-result: modelContent is not supported")
|
|
439
|
+
tool_call_id = command.get("toolCallId")
|
|
440
|
+
if not isinstance(tool_call_id, str):
|
|
441
|
+
raise _RunError("add-tool-result: toolCallId must be a string")
|
|
442
|
+
request_id = await self.get_input_request_id(tool_call_id)
|
|
443
|
+
if request_id is None:
|
|
444
|
+
raise _RunError(
|
|
445
|
+
f"add-tool-result: no input request for toolCallId {tool_call_id!r}"
|
|
446
|
+
)
|
|
447
|
+
response: dict[str, Any] = {"output": command.get("result")}
|
|
448
|
+
if command.get("isError"):
|
|
449
|
+
response["isError"] = True
|
|
450
|
+
if "artifact" in command:
|
|
451
|
+
response["artifact"] = command["artifact"]
|
|
452
|
+
return {"requestId": request_id, "response": response}
|
|
453
|
+
|
|
454
|
+
async def _legacy_reload(self, parent_id: str) -> dict[str, Any]:
|
|
455
|
+
source_id = await self.get_message_child_id(parent_id)
|
|
456
|
+
if source_id is None:
|
|
457
|
+
raise _RunError(f"reload: no child message for parentId {parent_id!r}")
|
|
458
|
+
return {"method": "run/reload", "params": [{"sourceId": source_id}]}
|
|
459
|
+
|
|
460
|
+
async def _assistant_transport_dispatch(self, method: str, identity: Any) -> None:
|
|
461
|
+
client_id = "at-" + secrets.token_urlsafe(9)
|
|
462
|
+
q, _ = self._register(client_id, identity)
|
|
463
|
+
try:
|
|
464
|
+
lease = self._clients[client_id].lease
|
|
465
|
+
assert lease is not None
|
|
466
|
+
await self._admit(
|
|
467
|
+
client_id, 1, {"commands": [{"method": method, "params": []}]}, lease
|
|
468
|
+
)
|
|
469
|
+
finally:
|
|
470
|
+
self._subscribers.pop(q, None)
|
|
471
|
+
|
|
323
472
|
async def _assistant_transport_run(
|
|
324
473
|
self,
|
|
325
474
|
run: _Run,
|
|
@@ -327,13 +476,14 @@ class AssistantTransport(Statewire):
|
|
|
327
476
|
client_id: str,
|
|
328
477
|
lease: str,
|
|
329
478
|
commands: list[dict[str, Any]],
|
|
479
|
+
parent_id: str | None,
|
|
330
480
|
) -> None:
|
|
331
481
|
submit = asyncio.create_task(
|
|
332
|
-
self._assistant_transport_submit(client_id, lease, commands)
|
|
482
|
+
self._assistant_transport_submit(client_id, lease, commands, parent_id)
|
|
333
483
|
)
|
|
334
484
|
q_get: asyncio.Task[Any] = asyncio.create_task(q.get())
|
|
335
485
|
try:
|
|
336
|
-
total = len(commands)
|
|
486
|
+
total = len(commands) or (1 if parent_id is not None else 0)
|
|
337
487
|
acked = 0
|
|
338
488
|
open_pending: set[int] = set()
|
|
339
489
|
try:
|