Skip to content

Commit d94c8db

Browse files
committed
Add end_session_confirm_message handshake
Previously the Python client sent end_session_message and immediately closed the WS. Any artifact_upload_request_message the runtime produced after that (from chokidar events that fired post-terminal, e.g. files created by post-agent commands) was sent on a half-closed socket and the upload_response could never make it back. New protocol: - Runtime receives end_session_message, drains the artifact watcher, sends end_session_confirm_message, then closes. - Python client sends end_session_message, then pumps the receive loop — handling late artifact_upload_request_messages with the session's most-recent callback — until confirm arrives (or timeout). Breaking change: older clients that close without waiting for confirm will no longer get late artifacts uploaded. Both packages bumped to 0.12.0.
1 parent b05bff2 commit d94c8db

13 files changed

Lines changed: 156 additions & 22 deletions

File tree

packages/runtimeuse-client-python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "runtimeuse-client"
7-
version = "0.11.0"
7+
version = "0.12.0"
88
description = "Client library for AI agent runtime communication over WebSocket"
99
readme = "README.md"
1010
license = {"text" = "FSL"}

packages/runtimeuse-client-python/src/runtimeuse_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
ErrorMessageInterface,
2929
CancelMessage,
3030
EndSessionMessage,
31+
EndSessionConfirmMessage,
3132
ArtifactUploadResult,
3233
OnAssistantMessageCallback,
3334
OnArtifactUploadRequestCallback,
@@ -63,6 +64,7 @@
6364
"ErrorMessageInterface",
6465
"CancelMessage",
6566
"EndSessionMessage",
67+
"EndSessionConfirmMessage",
6668
"ArtifactUploadResult",
6769
"OnAssistantMessageCallback",
6870
"OnArtifactUploadRequestCallback",

packages/runtimeuse-client-python/src/runtimeuse_client/client.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AssistantMessageInterface,
2121
ArtifactUploadRequestMessageInterface,
2222
ArtifactUploadResponseMessageInterface,
23+
OnArtifactUploadRequestCallback,
2324
QueryOptions,
2425
ExecuteCommandsOptions,
2526
)
@@ -183,6 +184,11 @@ def __init__(self, connected: ConnectedTransport):
183184
self._abort_event = asyncio.Event()
184185
self._send_queue: asyncio.Queue[dict] | None = None
185186
self._lock = asyncio.Lock()
187+
# Track the most recent artifact upload callback so we can keep
188+
# handling late artifact requests during session close (after the
189+
# last query's request loop has exited).
190+
self._last_artifact_cb: OnArtifactUploadRequestCallback | None = None
191+
self._end_session_timeout_s: float = 60.0
186192

187193
def abort(self) -> None:
188194
"""Signal the in-flight request to cancel.
@@ -202,6 +208,8 @@ async def query(self, prompt: str, options: QueryOptions) -> QueryResult:
202208
async with self._lock:
203209
logger = options.logger or _default_logger
204210
self._abort_event = asyncio.Event()
211+
if options.on_artifact_upload_request is not None:
212+
self._last_artifact_cb = options.on_artifact_upload_request
205213

206214
invocation = _build_invocation(prompt, options)
207215
send_queue: asyncio.Queue[dict] = asyncio.Queue()
@@ -238,6 +246,8 @@ async def execute_commands(
238246
async with self._lock:
239247
logger = options.logger or _default_logger
240248
self._abort_event = asyncio.Event()
249+
if options.on_artifact_upload_request is not None:
250+
self._last_artifact_cb = options.on_artifact_upload_request
241251

242252
message = _build_command_execution(commands, options)
243253
send_queue: asyncio.Queue[dict] = asyncio.Queue()
@@ -266,6 +276,47 @@ async def execute_commands(
266276

267277
return CommandExecutionResult(results=wire.results)
268278

279+
async def _end_session(self) -> None:
280+
"""Send end_session_message and drain late artifact uploads until the
281+
runtime confirms. Called by the session context manager on exit.
282+
"""
283+
end_session = getattr(self._connected, "end_session", None)
284+
if end_session is None:
285+
# Older/alternate transports: nothing to drain.
286+
return
287+
288+
async def on_message(msg: dict) -> dict | None:
289+
if msg.get("message_type") != "artifact_upload_request_message":
290+
return None
291+
if self._last_artifact_cb is None:
292+
return None
293+
try:
294+
req = ArtifactUploadRequestMessageInterface.model_validate(msg)
295+
except pydantic.ValidationError:
296+
_default_logger.error(
297+
f"Malformed artifact upload request during end-of-session drain: {msg}"
298+
)
299+
return None
300+
try:
301+
result = await self._last_artifact_cb(req)
302+
except Exception:
303+
_default_logger.exception(
304+
"Artifact upload callback failed during end-of-session drain"
305+
)
306+
return None
307+
response = ArtifactUploadResponseMessageInterface(
308+
message_type="artifact_upload_response_message",
309+
filename=req.filename,
310+
filepath=req.filepath,
311+
presigned_url=result.presigned_url,
312+
content_type=result.content_type,
313+
)
314+
return response.model_dump(mode="json")
315+
316+
await end_session(
317+
on_message=on_message, timeout_s=self._end_session_timeout_s
318+
)
319+
269320

270321
class RuntimeUseClient:
271322
"""Client for communicating with a runtimeuse agent runtime.
@@ -337,7 +388,11 @@ async def session(self) -> AsyncIterator[RuntimeUseSession]:
337388
"Use a transport that implements PersistentTransport (e.g. WebSocketTransport)."
338389
)
339390
async with connect() as connected:
340-
yield RuntimeUseSession(connected)
391+
runtime_session = RuntimeUseSession(connected)
392+
try:
393+
yield runtime_session
394+
finally:
395+
await runtime_session._end_session()
341396

342397
async def query(
343398
self,

packages/runtimeuse-client-python/src/runtimeuse_client/transports/transport.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import asyncio
2-
from typing import Any, AsyncGenerator, AsyncContextManager, Protocol
2+
from typing import Any, AsyncGenerator, AsyncContextManager, Awaitable, Callable, Protocol
3+
4+
5+
EndSessionMessageHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]]
36

47

58
class Transport(Protocol):
@@ -30,6 +33,12 @@ def request(
3033
self, send_queue: asyncio.Queue[dict]
3134
) -> AsyncGenerator[dict[str, Any], None]: ...
3235

36+
async def end_session(
37+
self,
38+
on_message: EndSessionMessageHandler | None = None,
39+
timeout_s: float = 60.0,
40+
) -> None: ...
41+
3342
async def close(self) -> None: ...
3443

3544

packages/runtimeuse-client-python/src/runtimeuse_client/transports/websocket_transport.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import websockets
88

9+
from .transport import EndSessionMessageHandler
10+
911
_logger = logging.getLogger(__name__)
1012

1113

@@ -41,15 +43,59 @@ async def request(
4143
except asyncio.CancelledError:
4244
pass
4345

44-
async def close(self, send_end_message: bool = True) -> None:
45-
"""Close the connection, optionally sending end_session_message first."""
46-
if send_end_message:
47-
try:
48-
await self._ws.send(
49-
json.dumps({"message_type": "end_session_message"})
50-
)
51-
except websockets.exceptions.ConnectionClosed:
52-
pass
46+
async def end_session(
47+
self,
48+
on_message: EndSessionMessageHandler | None = None,
49+
timeout_s: float = 60.0,
50+
) -> None:
51+
"""Send ``end_session_message`` and pump the receive loop until the
52+
server's ``end_session_confirm_message`` arrives (or timeout).
53+
54+
The runtime drains its artifact watcher before confirming, so late
55+
``artifact_upload_request_message``s may arrive in this window. For
56+
each incoming message, ``on_message`` is awaited and may return a
57+
dict response to send back over the socket.
58+
"""
59+
try:
60+
await self._ws.send(
61+
json.dumps({"message_type": "end_session_message"})
62+
)
63+
except websockets.exceptions.ConnectionClosed:
64+
return
65+
66+
try:
67+
async with asyncio.timeout(timeout_s):
68+
async for raw in self._ws:
69+
try:
70+
msg = json.loads(raw)
71+
except json.JSONDecodeError:
72+
continue
73+
if msg.get("message_type") == "end_session_confirm_message":
74+
return
75+
if on_message is None:
76+
continue
77+
try:
78+
response = await on_message(msg)
79+
except Exception:
80+
_logger.exception(
81+
"Error handling message during end_session drain"
82+
)
83+
continue
84+
if response is None:
85+
continue
86+
try:
87+
await self._ws.send(json.dumps(response))
88+
except websockets.exceptions.ConnectionClosed:
89+
return
90+
except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed):
91+
return
92+
93+
async def close(self) -> None:
94+
"""Close the underlying socket without sending end_session_message.
95+
96+
Callers that need a graceful session end should call
97+
:meth:`end_session` first.
98+
"""
5399
await self._ws.close()
54100

55101
async def _queue_sender(self, send_queue: asyncio.Queue[dict]) -> None:
@@ -89,13 +135,15 @@ async def __call__(
89135
async def connect(self) -> AsyncIterator[ConnectedWebSocketTransport]:
90136
"""Open a persistent WebSocket connection for use with a session.
91137
92-
Sends ``end_session_message`` and closes the socket on exit.
138+
The connection is closed on exit. Callers that want a graceful session
139+
end (draining late artifacts) should call
140+
:meth:`ConnectedWebSocketTransport.end_session` before exiting the
141+
context.
93142
"""
94143
_logger.info("Connecting persistent WebSocket to %s", self.ws_url)
95144
async with websockets.connect(self.ws_url, open_timeout=60) as ws:
96145
connected = ConnectedWebSocketTransport(ws)
97146
try:
98147
yield connected
99148
finally:
100-
await connected.close(send_end_message=True)
101149
_logger.info("Persistent agent runtime connection closed")

packages/runtimeuse-client-python/src/runtimeuse_client/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ class EndSessionMessage(BaseModel):
106106
message_type: Literal["end_session_message"]
107107

108108

109+
class EndSessionConfirmMessage(BaseModel):
110+
message_type: Literal["end_session_confirm_message"]
111+
112+
109113
class CommandExecutionMessage(BaseModel):
110114
message_type: Literal["command_execution_message"]
111115
source_id: str | None = None

packages/runtimeuse-client-python/test/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ async def request(
6767
finally:
6868
self._drain(send_queue)
6969

70+
async def end_session(self, on_message=None, timeout_s: float = 60.0) -> None:
71+
return None
72+
7073
async def close(self) -> None:
7174
self.closed = True
7275

packages/runtimeuse-client-python/test/test_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,9 @@ async def request(self, send_queue: asyncio.Queue[dict]):
982982
finally:
983983
self._drain_send(send_queue)
984984

985+
async def end_session(self, on_message=None, timeout_s: float = 60.0) -> None:
986+
return None
987+
985988
async def close(self) -> None:
986989
self.closed = True
987990

packages/runtimeuse-client-python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/runtimeuse/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)