indent 0.1.21__py3-none-any.whl → 0.1.23__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.
Potentially problematic release.
This version of indent might be problematic. Click here for more details.
- exponent/__init__.py +2 -2
- exponent/commands/cloud_commands.py +2 -0
- exponent/core/graphql/mutations.py +2 -2
- exponent/core/remote_execution/cli_rpc_types.py +117 -0
- exponent/core/remote_execution/client.py +219 -32
- exponent/core/remote_execution/code_execution.py +26 -7
- exponent/core/remote_execution/languages/shell_streaming.py +2 -4
- exponent/core/remote_execution/port_utils.py +73 -0
- exponent/core/remote_execution/system_context.py +2 -0
- exponent/core/remote_execution/terminal_session.py +429 -0
- exponent/core/remote_execution/terminal_types.py +29 -0
- exponent/core/remote_execution/tool_execution.py +4 -4
- exponent/core/remote_execution/truncation.py +1 -1
- exponent/core/remote_execution/types.py +9 -0
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/METADATA +2 -1
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/RECORD +18 -15
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/WHEEL +0 -0
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/entry_points.txt +0 -0
exponent/__init__.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.1.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.23'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 23)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -111,6 +111,7 @@ async def create_cloud_chat_from_repository(
|
|
|
111
111
|
base_api_url: str,
|
|
112
112
|
base_ws_url: str,
|
|
113
113
|
repository_id: str,
|
|
114
|
+
provider: str | None = None,
|
|
114
115
|
) -> dict[str, Any]:
|
|
115
116
|
graphql_client = GraphQLClient(
|
|
116
117
|
api_key=api_key, base_api_url=base_api_url, base_ws_url=base_ws_url
|
|
@@ -118,6 +119,7 @@ async def create_cloud_chat_from_repository(
|
|
|
118
119
|
|
|
119
120
|
variables = {
|
|
120
121
|
"repositoryId": repository_id,
|
|
122
|
+
"provider": provider,
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
result = await graphql_client.execute(
|
|
@@ -76,8 +76,8 @@ mutation CreateCloudChat($configId: String!) {
|
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
CREATE_CLOUD_CHAT_FROM_REPOSITORY_MUTATION = """
|
|
79
|
-
mutation CreateCloudChatFromRepository($repositoryId: String
|
|
80
|
-
createCloudChat(repositoryId: $repositoryId) {
|
|
79
|
+
mutation CreateCloudChatFromRepository($repositoryId: String!, $provider: String) {
|
|
80
|
+
createCloudChat(repositoryId: $repositoryId, provider: $provider) {
|
|
81
81
|
__typename
|
|
82
82
|
...on Chat {
|
|
83
83
|
chatUuid
|
|
@@ -304,6 +304,77 @@ class GenerateUploadUrlResponse(msgspec.Struct, tag="generate_upload_url"):
|
|
|
304
304
|
s3_uri: str
|
|
305
305
|
|
|
306
306
|
|
|
307
|
+
# Terminal session management
|
|
308
|
+
class StartTerminalRequest(msgspec.Struct, tag="start_terminal"):
|
|
309
|
+
"""Start a new terminal session with PTY"""
|
|
310
|
+
|
|
311
|
+
session_id: str
|
|
312
|
+
command: list[str] | None = None # None = default shell, or specific command
|
|
313
|
+
cols: int = 80
|
|
314
|
+
rows: int = 24
|
|
315
|
+
env: dict[str, str] | None = None # Additional environment variables
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class StartTerminalResponse(msgspec.Struct, tag="start_terminal"):
|
|
319
|
+
"""Response after starting terminal"""
|
|
320
|
+
|
|
321
|
+
session_id: str
|
|
322
|
+
success: bool
|
|
323
|
+
error_message: str | None = None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# Terminal input (user typing)
|
|
327
|
+
class TerminalInputRequest(msgspec.Struct, tag="terminal_input"):
|
|
328
|
+
"""Send user input to terminal"""
|
|
329
|
+
|
|
330
|
+
session_id: str
|
|
331
|
+
data: str # Raw input data from user
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TerminalInputResponse(msgspec.Struct, tag="terminal_input"):
|
|
335
|
+
"""Acknowledge input received"""
|
|
336
|
+
|
|
337
|
+
session_id: str
|
|
338
|
+
success: bool
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Terminal resize
|
|
342
|
+
class TerminalResizeRequest(msgspec.Struct, tag="terminal_resize"):
|
|
343
|
+
"""Resize terminal dimensions"""
|
|
344
|
+
|
|
345
|
+
session_id: str
|
|
346
|
+
cols: int
|
|
347
|
+
rows: int
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class TerminalResizeResponse(msgspec.Struct, tag="terminal_resize"):
|
|
351
|
+
"""Acknowledge resize"""
|
|
352
|
+
|
|
353
|
+
session_id: str
|
|
354
|
+
success: bool
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# Terminal stop
|
|
358
|
+
class StopTerminalRequest(msgspec.Struct, tag="stop_terminal"):
|
|
359
|
+
"""Stop a terminal session"""
|
|
360
|
+
|
|
361
|
+
session_id: str
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class StopTerminalResponse(msgspec.Struct, tag="stop_terminal"):
|
|
365
|
+
"""Acknowledge terminal stopped"""
|
|
366
|
+
|
|
367
|
+
session_id: str
|
|
368
|
+
success: bool
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class StreamingCodeExecutionRequest(msgspec.Struct, tag="streaming_code_execution"):
|
|
372
|
+
correlation_id: str
|
|
373
|
+
language: str # "python" or "shell"
|
|
374
|
+
content: str
|
|
375
|
+
timeout: int
|
|
376
|
+
|
|
377
|
+
|
|
307
378
|
class CliRpcRequest(msgspec.Struct):
|
|
308
379
|
request_id: str
|
|
309
380
|
request: (
|
|
@@ -315,6 +386,11 @@ class CliRpcRequest(msgspec.Struct):
|
|
|
315
386
|
| SwitchCLIChatRequest
|
|
316
387
|
| KeepAliveCliChatRequest
|
|
317
388
|
| GenerateUploadUrlRequest
|
|
389
|
+
| StartTerminalRequest
|
|
390
|
+
| TerminalInputRequest
|
|
391
|
+
| TerminalResizeRequest
|
|
392
|
+
| StopTerminalRequest
|
|
393
|
+
| StreamingCodeExecutionRequest
|
|
318
394
|
)
|
|
319
395
|
|
|
320
396
|
|
|
@@ -326,6 +402,40 @@ class ErrorResponse(msgspec.Struct, tag="error"):
|
|
|
326
402
|
error_message: str
|
|
327
403
|
|
|
328
404
|
|
|
405
|
+
class StreamingCodeExecutionResponseChunk(
|
|
406
|
+
msgspec.Struct, tag="streaming_code_execution_chunk"
|
|
407
|
+
):
|
|
408
|
+
correlation_id: str
|
|
409
|
+
content: str
|
|
410
|
+
truncated: bool = False
|
|
411
|
+
|
|
412
|
+
def add(
|
|
413
|
+
self, new_chunk: "StreamingCodeExecutionResponseChunk"
|
|
414
|
+
) -> "StreamingCodeExecutionResponseChunk":
|
|
415
|
+
"""Aggregates content of this and a new chunk."""
|
|
416
|
+
assert self.correlation_id == new_chunk.correlation_id
|
|
417
|
+
return StreamingCodeExecutionResponseChunk(
|
|
418
|
+
correlation_id=self.correlation_id,
|
|
419
|
+
content=self.content + new_chunk.content,
|
|
420
|
+
truncated=self.truncated or new_chunk.truncated,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class StreamingCodeExecutionResponse(msgspec.Struct, tag="streaming_code_execution"):
|
|
425
|
+
correlation_id: str
|
|
426
|
+
content: str
|
|
427
|
+
truncated: bool = False
|
|
428
|
+
# Only present for shell code execution
|
|
429
|
+
cancelled_for_timeout: bool = False
|
|
430
|
+
exit_code: int | None = None
|
|
431
|
+
halted: bool = False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class StreamingErrorResponse(msgspec.Struct, tag="streaming_error"):
|
|
435
|
+
correlation_id: str
|
|
436
|
+
error_message: str
|
|
437
|
+
|
|
438
|
+
|
|
329
439
|
class CliRpcResponse(msgspec.Struct):
|
|
330
440
|
request_id: str
|
|
331
441
|
response: (
|
|
@@ -339,4 +449,11 @@ class CliRpcResponse(msgspec.Struct):
|
|
|
339
449
|
| SwitchCLIChatResponse
|
|
340
450
|
| KeepAliveCliChatResponse
|
|
341
451
|
| GenerateUploadUrlResponse
|
|
452
|
+
| StartTerminalResponse
|
|
453
|
+
| TerminalInputResponse
|
|
454
|
+
| TerminalResizeResponse
|
|
455
|
+
| StopTerminalResponse
|
|
456
|
+
| StreamingCodeExecutionResponseChunk
|
|
457
|
+
| StreamingCodeExecutionResponse
|
|
458
|
+
| StreamingErrorResponse
|
|
342
459
|
)
|
|
@@ -38,8 +38,16 @@ from exponent.core.remote_execution.cli_rpc_types import (
|
|
|
38
38
|
HttpRequest,
|
|
39
39
|
KeepAliveCliChatRequest,
|
|
40
40
|
KeepAliveCliChatResponse,
|
|
41
|
+
StartTerminalRequest,
|
|
42
|
+
StartTerminalResponse,
|
|
43
|
+
StopTerminalRequest,
|
|
44
|
+
StopTerminalResponse,
|
|
41
45
|
SwitchCLIChatRequest,
|
|
42
46
|
SwitchCLIChatResponse,
|
|
47
|
+
TerminalInputRequest,
|
|
48
|
+
TerminalInputResponse,
|
|
49
|
+
TerminalResizeRequest,
|
|
50
|
+
TerminalResizeResponse,
|
|
43
51
|
TerminateRequest,
|
|
44
52
|
TerminateResponse,
|
|
45
53
|
ToolExecutionRequest,
|
|
@@ -56,6 +64,8 @@ from exponent.core.remote_execution.session import (
|
|
|
56
64
|
get_session,
|
|
57
65
|
send_exception_log,
|
|
58
66
|
)
|
|
67
|
+
from exponent.core.remote_execution.terminal_session import TerminalSessionManager
|
|
68
|
+
from exponent.core.remote_execution.terminal_types import TerminalMessage
|
|
59
69
|
from exponent.core.remote_execution.tool_execution import (
|
|
60
70
|
execute_bash_tool,
|
|
61
71
|
execute_tool,
|
|
@@ -66,9 +76,7 @@ from exponent.core.remote_execution.types import (
|
|
|
66
76
|
CLIConnectedState,
|
|
67
77
|
CreateChatResponse,
|
|
68
78
|
HeartbeatInfo,
|
|
69
|
-
RemoteExecutionResponseType,
|
|
70
79
|
RunWorkflowRequest,
|
|
71
|
-
StreamingCodeExecutionRequest,
|
|
72
80
|
WorkflowInput,
|
|
73
81
|
WorkflowTriggerRequest,
|
|
74
82
|
WorkflowTriggerResponse,
|
|
@@ -183,11 +191,12 @@ class RemoteExecutionClient:
|
|
|
183
191
|
# Handle cancellation gracefully
|
|
184
192
|
return None
|
|
185
193
|
|
|
186
|
-
async def _handle_websocket_message(
|
|
194
|
+
async def _handle_websocket_message( # noqa: PLR0911, PLR0915
|
|
187
195
|
self,
|
|
188
196
|
msg: str,
|
|
189
197
|
websocket: ClientConnection,
|
|
190
198
|
requests: asyncio.Queue[CliRpcRequest],
|
|
199
|
+
terminal_session_manager: TerminalSessionManager,
|
|
191
200
|
) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
|
|
192
201
|
"""Handle an incoming websocket message.
|
|
193
202
|
Returns None to continue processing, or a REMOTE_EXECUTION_CLIENT_EXIT_INFO to exit."""
|
|
@@ -215,7 +224,7 @@ class RemoteExecutionClient:
|
|
|
215
224
|
data = json.dumps(msg_data["data"])
|
|
216
225
|
try:
|
|
217
226
|
request = msgspec.json.decode(data, type=CliRpcRequest)
|
|
218
|
-
except msgspec.DecodeError as e:
|
|
227
|
+
except (msgspec.DecodeError, msgspec.ValidationError) as e:
|
|
219
228
|
# Try and decode to get request_id if possible
|
|
220
229
|
request = msgspec.json.decode(data)
|
|
221
230
|
if isinstance(request, dict) and "request_id" in request:
|
|
@@ -317,6 +326,101 @@ class RemoteExecutionClient:
|
|
|
317
326
|
)
|
|
318
327
|
)
|
|
319
328
|
return None
|
|
329
|
+
elif isinstance(request.request, StartTerminalRequest):
|
|
330
|
+
# Start a new terminal session
|
|
331
|
+
session_id = await terminal_session_manager.start_session(
|
|
332
|
+
websocket=websocket,
|
|
333
|
+
session_id=request.request.session_id,
|
|
334
|
+
command=request.request.command,
|
|
335
|
+
cols=request.request.cols,
|
|
336
|
+
rows=request.request.rows,
|
|
337
|
+
env=request.request.env,
|
|
338
|
+
)
|
|
339
|
+
await websocket.send(
|
|
340
|
+
json.dumps(
|
|
341
|
+
{
|
|
342
|
+
"type": "result",
|
|
343
|
+
"data": msgspec.to_builtins(
|
|
344
|
+
CliRpcResponse(
|
|
345
|
+
request_id=request.request_id,
|
|
346
|
+
response=StartTerminalResponse(
|
|
347
|
+
session_id=session_id, success=True
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return None
|
|
355
|
+
elif isinstance(request.request, TerminalInputRequest):
|
|
356
|
+
# Send input to terminal session
|
|
357
|
+
success = await terminal_session_manager.send_input(
|
|
358
|
+
session_id=request.request.session_id,
|
|
359
|
+
data=request.request.data,
|
|
360
|
+
)
|
|
361
|
+
await websocket.send(
|
|
362
|
+
json.dumps(
|
|
363
|
+
{
|
|
364
|
+
"type": "result",
|
|
365
|
+
"data": msgspec.to_builtins(
|
|
366
|
+
CliRpcResponse(
|
|
367
|
+
request_id=request.request_id,
|
|
368
|
+
response=TerminalInputResponse(
|
|
369
|
+
session_id=request.request.session_id,
|
|
370
|
+
success=success,
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
),
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
return None
|
|
378
|
+
elif isinstance(request.request, TerminalResizeRequest):
|
|
379
|
+
# Resize terminal session
|
|
380
|
+
success = await terminal_session_manager.resize_terminal(
|
|
381
|
+
session_id=request.request.session_id,
|
|
382
|
+
rows=request.request.rows,
|
|
383
|
+
cols=request.request.cols,
|
|
384
|
+
)
|
|
385
|
+
await websocket.send(
|
|
386
|
+
json.dumps(
|
|
387
|
+
{
|
|
388
|
+
"type": "result",
|
|
389
|
+
"data": msgspec.to_builtins(
|
|
390
|
+
CliRpcResponse(
|
|
391
|
+
request_id=request.request_id,
|
|
392
|
+
response=TerminalResizeResponse(
|
|
393
|
+
session_id=request.request.session_id,
|
|
394
|
+
success=success,
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
),
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
return None
|
|
402
|
+
elif isinstance(request.request, StopTerminalRequest):
|
|
403
|
+
# Stop terminal session
|
|
404
|
+
success = await terminal_session_manager.stop_session(
|
|
405
|
+
session_id=request.request.session_id
|
|
406
|
+
)
|
|
407
|
+
await websocket.send(
|
|
408
|
+
json.dumps(
|
|
409
|
+
{
|
|
410
|
+
"type": "result",
|
|
411
|
+
"data": msgspec.to_builtins(
|
|
412
|
+
CliRpcResponse(
|
|
413
|
+
request_id=request.request_id,
|
|
414
|
+
response=StopTerminalResponse(
|
|
415
|
+
session_id=request.request.session_id,
|
|
416
|
+
success=success,
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
return None
|
|
320
424
|
else:
|
|
321
425
|
if isinstance(request.request, ToolExecutionRequest) and isinstance(
|
|
322
426
|
request.request.tool_input, BashToolInput
|
|
@@ -363,21 +467,31 @@ class RemoteExecutionClient:
|
|
|
363
467
|
request = await requests.get()
|
|
364
468
|
|
|
365
469
|
try:
|
|
366
|
-
# if
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
470
|
+
# Check if this is a streaming request
|
|
471
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
472
|
+
StreamingCodeExecutionRequest,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if isinstance(request.request, StreamingCodeExecutionRequest):
|
|
476
|
+
async for streaming_response in self.handle_streaming_request(
|
|
477
|
+
request.request
|
|
478
|
+
):
|
|
479
|
+
async with results_lock:
|
|
480
|
+
await results.put(
|
|
481
|
+
CliRpcResponse(
|
|
482
|
+
request_id=request.request_id,
|
|
483
|
+
response=streaming_response,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
else:
|
|
487
|
+
# Note that we don't want to hold the lock here
|
|
488
|
+
# because we want other executors to be able to
|
|
489
|
+
# grab requests while we're handling a request.
|
|
490
|
+
logger.info(f"Handling request {request}")
|
|
491
|
+
response = await self.handle_request(request)
|
|
492
|
+
async with results_lock:
|
|
493
|
+
logger.info(f"Putting response {response}")
|
|
494
|
+
await results.put(response)
|
|
381
495
|
except Exception as e:
|
|
382
496
|
logger.info(f"Error handling request {request}:\n\n{e}")
|
|
383
497
|
try:
|
|
@@ -385,15 +499,32 @@ class RemoteExecutionClient:
|
|
|
385
499
|
except Exception:
|
|
386
500
|
pass
|
|
387
501
|
async with results_lock:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
response=ErrorResponse(
|
|
392
|
-
error_message=str(e),
|
|
393
|
-
),
|
|
394
|
-
)
|
|
502
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
503
|
+
StreamingCodeExecutionRequest,
|
|
504
|
+
StreamingErrorResponse,
|
|
395
505
|
)
|
|
396
506
|
|
|
507
|
+
if isinstance(request.request, StreamingCodeExecutionRequest):
|
|
508
|
+
# For streaming requests, send a streaming error response
|
|
509
|
+
await results.put(
|
|
510
|
+
CliRpcResponse(
|
|
511
|
+
request_id=request.request_id,
|
|
512
|
+
response=StreamingErrorResponse(
|
|
513
|
+
correlation_id=request.request.correlation_id,
|
|
514
|
+
error_message=str(e),
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
await results.put(
|
|
520
|
+
CliRpcResponse(
|
|
521
|
+
request_id=request.request_id,
|
|
522
|
+
response=ErrorResponse(
|
|
523
|
+
error_message=str(e),
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
397
528
|
beat_task = asyncio.create_task(beat())
|
|
398
529
|
# Three parallel executors to handle requests
|
|
399
530
|
|
|
@@ -411,6 +542,8 @@ class RemoteExecutionClient:
|
|
|
411
542
|
beats: asyncio.Queue[HeartbeatInfo],
|
|
412
543
|
requests: asyncio.Queue[CliRpcRequest],
|
|
413
544
|
results: asyncio.Queue[CliRpcResponse],
|
|
545
|
+
terminal_output_queue: asyncio.Queue[TerminalMessage],
|
|
546
|
+
terminal_session_manager: TerminalSessionManager,
|
|
414
547
|
) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO:
|
|
415
548
|
"""Process messages from the websocket connection."""
|
|
416
549
|
pending: set[asyncio.Task[object]] = set()
|
|
@@ -418,7 +551,8 @@ class RemoteExecutionClient:
|
|
|
418
551
|
recv = asyncio.create_task(websocket.recv())
|
|
419
552
|
get_beat = asyncio.create_task(beats.get())
|
|
420
553
|
get_result = asyncio.create_task(results.get())
|
|
421
|
-
|
|
554
|
+
get_terminal_output = asyncio.create_task(terminal_output_queue.get())
|
|
555
|
+
pending = {recv, get_beat, get_result, get_terminal_output}
|
|
422
556
|
|
|
423
557
|
while True:
|
|
424
558
|
done, pending = await asyncio.wait(
|
|
@@ -428,7 +562,7 @@ class RemoteExecutionClient:
|
|
|
428
562
|
if recv in done:
|
|
429
563
|
msg = str(recv.result())
|
|
430
564
|
exit_info = await self._handle_websocket_message(
|
|
431
|
-
msg, websocket, requests
|
|
565
|
+
msg, websocket, requests, terminal_session_manager
|
|
432
566
|
)
|
|
433
567
|
if exit_info is not None:
|
|
434
568
|
return exit_info
|
|
@@ -447,12 +581,24 @@ class RemoteExecutionClient:
|
|
|
447
581
|
|
|
448
582
|
if get_result in done:
|
|
449
583
|
response = get_result.result()
|
|
584
|
+
# All responses are now CliRpcResponse with msgspec
|
|
450
585
|
data = msgspec.to_builtins(response)
|
|
451
586
|
msg = json.dumps({"type": "result", "data": data})
|
|
452
587
|
await websocket.send(msg)
|
|
453
588
|
|
|
454
589
|
get_result = asyncio.create_task(results.get())
|
|
455
590
|
pending.add(get_result)
|
|
591
|
+
|
|
592
|
+
if get_terminal_output in done:
|
|
593
|
+
terminal_message = get_terminal_output.result()
|
|
594
|
+
data = msgspec.to_builtins(terminal_message)
|
|
595
|
+
msg = json.dumps({"type": "terminal_message", "data": data})
|
|
596
|
+
await websocket.send(msg)
|
|
597
|
+
|
|
598
|
+
get_terminal_output = asyncio.create_task(
|
|
599
|
+
terminal_output_queue.get()
|
|
600
|
+
)
|
|
601
|
+
pending.add(get_terminal_output)
|
|
456
602
|
finally:
|
|
457
603
|
for task in pending:
|
|
458
604
|
task.cancel()
|
|
@@ -466,6 +612,8 @@ class RemoteExecutionClient:
|
|
|
466
612
|
beats: asyncio.Queue[HeartbeatInfo],
|
|
467
613
|
requests: asyncio.Queue[CliRpcRequest],
|
|
468
614
|
results: asyncio.Queue[CliRpcResponse],
|
|
615
|
+
terminal_output_queue: asyncio.Queue[TerminalMessage],
|
|
616
|
+
terminal_session_manager: TerminalSessionManager,
|
|
469
617
|
) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
|
|
470
618
|
"""Handle a single websocket connection.
|
|
471
619
|
Returns None to continue with reconnection attempts, or an exit info to terminate."""
|
|
@@ -476,7 +624,12 @@ class RemoteExecutionClient:
|
|
|
476
624
|
|
|
477
625
|
try:
|
|
478
626
|
return await self._process_websocket_messages(
|
|
479
|
-
websocket,
|
|
627
|
+
websocket,
|
|
628
|
+
beats,
|
|
629
|
+
requests,
|
|
630
|
+
results,
|
|
631
|
+
terminal_output_queue,
|
|
632
|
+
terminal_session_manager,
|
|
480
633
|
)
|
|
481
634
|
except websockets.exceptions.ConnectionClosed as e:
|
|
482
635
|
if e.rcvd is not None:
|
|
@@ -517,6 +670,10 @@ class RemoteExecutionClient:
|
|
|
517
670
|
beats: asyncio.Queue[HeartbeatInfo] = asyncio.Queue()
|
|
518
671
|
requests: asyncio.Queue[CliRpcRequest] = asyncio.Queue()
|
|
519
672
|
results: asyncio.Queue[CliRpcResponse] = asyncio.Queue()
|
|
673
|
+
terminal_output_queue: asyncio.Queue[TerminalMessage] = asyncio.Queue()
|
|
674
|
+
|
|
675
|
+
# Create terminal session manager ONCE - persist across reconnections
|
|
676
|
+
terminal_session_manager = TerminalSessionManager(terminal_output_queue)
|
|
520
677
|
|
|
521
678
|
# Create tasks ONCE - persist across reconnections
|
|
522
679
|
executors = await self._setup_tasks(beats, requests, results)
|
|
@@ -529,7 +686,13 @@ class RemoteExecutionClient:
|
|
|
529
686
|
[
|
|
530
687
|
asyncio.create_task(
|
|
531
688
|
self._handle_websocket_connection(
|
|
532
|
-
websocket,
|
|
689
|
+
websocket,
|
|
690
|
+
connection_tracker,
|
|
691
|
+
beats,
|
|
692
|
+
requests,
|
|
693
|
+
results,
|
|
694
|
+
terminal_output_queue,
|
|
695
|
+
terminal_session_manager,
|
|
533
696
|
)
|
|
534
697
|
),
|
|
535
698
|
asyncio.create_task(self._timeout_monitor(timeout_seconds)),
|
|
@@ -554,6 +717,9 @@ class RemoteExecutionClient:
|
|
|
554
717
|
error_message="Could not establish websocket connection"
|
|
555
718
|
)
|
|
556
719
|
finally:
|
|
720
|
+
# Stop all terminal sessions to clean up PTY processes
|
|
721
|
+
await terminal_session_manager.stop_all_sessions()
|
|
722
|
+
|
|
557
723
|
# Cancel all background tasks when exiting
|
|
558
724
|
for task in executors:
|
|
559
725
|
task.cancel()
|
|
@@ -733,6 +899,22 @@ class RemoteExecutionClient:
|
|
|
733
899
|
raise ValueError(
|
|
734
900
|
"KeepAliveCliChatRequest should not be handled by handle_request"
|
|
735
901
|
)
|
|
902
|
+
elif isinstance(request.request, StartTerminalRequest):
|
|
903
|
+
raise ValueError(
|
|
904
|
+
"StartTerminalRequest should not be handled by handle_request"
|
|
905
|
+
)
|
|
906
|
+
elif isinstance(request.request, TerminalInputRequest):
|
|
907
|
+
raise ValueError(
|
|
908
|
+
"TerminalInputRequest should not be handled by handle_request"
|
|
909
|
+
)
|
|
910
|
+
elif isinstance(request.request, TerminalResizeRequest):
|
|
911
|
+
raise ValueError(
|
|
912
|
+
"TerminalResizeRequest should not be handled by handle_request"
|
|
913
|
+
)
|
|
914
|
+
elif isinstance(request.request, StopTerminalRequest):
|
|
915
|
+
raise ValueError(
|
|
916
|
+
"StopTerminalRequest should not be handled by handle_request"
|
|
917
|
+
)
|
|
736
918
|
|
|
737
919
|
raise ValueError(f"Unhandled request type: {type(request)}")
|
|
738
920
|
|
|
@@ -754,8 +936,13 @@ class RemoteExecutionClient:
|
|
|
754
936
|
await self.clear_halt_state(request.request_id)
|
|
755
937
|
|
|
756
938
|
async def handle_streaming_request(
|
|
757
|
-
self,
|
|
758
|
-
|
|
939
|
+
self,
|
|
940
|
+
request: Any,
|
|
941
|
+
) -> AsyncGenerator[Any, None]:
|
|
942
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
943
|
+
StreamingCodeExecutionRequest,
|
|
944
|
+
)
|
|
945
|
+
|
|
759
946
|
if not isinstance(request, StreamingCodeExecutionRequest):
|
|
760
947
|
assert False, f"{type(request)} should be sent to handle_streaming_request"
|
|
761
948
|
async for output in execute_code_streaming(
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from collections.abc import AsyncGenerator, Callable
|
|
2
2
|
|
|
3
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
4
|
+
StreamingCodeExecutionRequest,
|
|
5
|
+
StreamingCodeExecutionResponse,
|
|
6
|
+
StreamingCodeExecutionResponseChunk,
|
|
7
|
+
)
|
|
3
8
|
from exponent.core.remote_execution.languages.python_execution import (
|
|
4
9
|
execute_python_streaming,
|
|
5
10
|
)
|
|
@@ -8,13 +13,9 @@ from exponent.core.remote_execution.languages.shell_streaming import (
|
|
|
8
13
|
)
|
|
9
14
|
from exponent.core.remote_execution.languages.types import StreamedOutputPiece
|
|
10
15
|
from exponent.core.remote_execution.session import RemoteExecutionClientSession
|
|
11
|
-
from exponent.core.remote_execution.types import (
|
|
12
|
-
StreamingCodeExecutionRequest,
|
|
13
|
-
StreamingCodeExecutionResponse,
|
|
14
|
-
StreamingCodeExecutionResponseChunk,
|
|
15
|
-
)
|
|
16
16
|
|
|
17
17
|
EMPTY_OUTPUT_STRING = "(No output)"
|
|
18
|
+
MAX_OUTPUT_LENGTH = 50000 # Maximum characters to keep in final output
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
async def execute_code_streaming(
|
|
@@ -34,9 +35,18 @@ async def execute_code_streaming(
|
|
|
34
35
|
content=output.content, correlation_id=request.correlation_id
|
|
35
36
|
)
|
|
36
37
|
else:
|
|
38
|
+
final_output = output.output or EMPTY_OUTPUT_STRING
|
|
39
|
+
truncated = len(final_output) > MAX_OUTPUT_LENGTH
|
|
40
|
+
if truncated:
|
|
41
|
+
final_output = final_output[-MAX_OUTPUT_LENGTH:]
|
|
42
|
+
final_output = (
|
|
43
|
+
f"[Truncated to last {MAX_OUTPUT_LENGTH} characters]\n\n"
|
|
44
|
+
+ final_output
|
|
45
|
+
)
|
|
37
46
|
yield StreamingCodeExecutionResponse(
|
|
38
47
|
correlation_id=request.correlation_id,
|
|
39
|
-
content=
|
|
48
|
+
content=final_output,
|
|
49
|
+
truncated=truncated,
|
|
40
50
|
halted=output.halted,
|
|
41
51
|
)
|
|
42
52
|
|
|
@@ -49,9 +59,18 @@ async def execute_code_streaming(
|
|
|
49
59
|
content=shell_output.content, correlation_id=request.correlation_id
|
|
50
60
|
)
|
|
51
61
|
else:
|
|
62
|
+
final_output = shell_output.output or EMPTY_OUTPUT_STRING
|
|
63
|
+
truncated = len(final_output) > MAX_OUTPUT_LENGTH
|
|
64
|
+
if truncated:
|
|
65
|
+
final_output = final_output[-MAX_OUTPUT_LENGTH:]
|
|
66
|
+
final_output = (
|
|
67
|
+
f"[Truncated to last {MAX_OUTPUT_LENGTH} characters]\n\n"
|
|
68
|
+
+ final_output
|
|
69
|
+
)
|
|
52
70
|
yield StreamingCodeExecutionResponse(
|
|
53
71
|
correlation_id=request.correlation_id,
|
|
54
|
-
content=
|
|
72
|
+
content=final_output,
|
|
73
|
+
truncated=truncated,
|
|
55
74
|
halted=shell_output.halted,
|
|
56
75
|
exit_code=shell_output.exit_code,
|
|
57
76
|
cancelled_for_timeout=shell_output.cancelled_for_timeout,
|
|
@@ -50,7 +50,7 @@ async def read_stream(
|
|
|
50
50
|
|
|
51
51
|
while True:
|
|
52
52
|
try:
|
|
53
|
-
data = await stream.read(
|
|
53
|
+
data = await stream.read(50_000)
|
|
54
54
|
if not data:
|
|
55
55
|
break
|
|
56
56
|
chunk = data.decode(encoding=encoding, errors="replace")
|
|
@@ -182,10 +182,8 @@ async def execute_shell_streaming( # noqa: PLR0915
|
|
|
182
182
|
piece = await task
|
|
183
183
|
yield piece
|
|
184
184
|
|
|
185
|
-
if process.returncode is not None:
|
|
186
|
-
break
|
|
187
|
-
|
|
188
185
|
# Schedule next read from the same stream
|
|
186
|
+
# Don't check process.returncode here - we need to drain all buffered output
|
|
189
187
|
if task is stdout_task and not process.stdout.at_eof():
|
|
190
188
|
stdout_task = asyncio.create_task(stdout_gen.__anext__())
|
|
191
189
|
pending.add(stdout_task)
|