Skip to content

Commit 1f0e720

Browse files
authored
Merge pull request #19 from getlark/vd-20260411-1734
Add execute_commands() for command-only execution without agent invocation
2 parents e61ebcd + 33157ea commit 1f0e720

19 files changed

Lines changed: 1046 additions & 23 deletions

File tree

docs/content/docs/python-client.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ result = await client.query(
147147
)
148148
```
149149

150+
## Run Commands Without the Agent
151+
152+
Use `execute_commands()` when you only need to run shell commands in the sandbox -- no agent invocation, no prompt. The method returns per-command exit codes and raises `AgentRuntimeError` if any command fails.
153+
154+
```python
155+
from runtimeuse_client import (
156+
CommandInterface,
157+
ExecuteCommandsOptions,
158+
RuntimeUseClient,
159+
)
160+
161+
client = RuntimeUseClient(ws_url="ws://localhost:8080")
162+
163+
result = await client.execute_commands(
164+
commands=[
165+
CommandInterface(command="mkdir -p /app/output"),
166+
CommandInterface(command="echo 'sandbox is ready' > /app/output/status.txt"),
167+
CommandInterface(command="cat /app/output/status.txt"),
168+
],
169+
options=ExecuteCommandsOptions(
170+
on_assistant_message=on_assistant_message, # streams stdout/stderr
171+
),
172+
)
173+
174+
for item in result.results:
175+
print(f"{item.command} -> exit {item.exit_code}")
176+
```
177+
178+
`execute_commands()` supports the same callbacks and options as `query()`: streaming via `on_assistant_message`, artifact uploads, cancellation, timeout, and `secrets_to_redact`. Use `pre_execution_downloadables` to fetch files into the sandbox before the commands run.
179+
150180
## Cancel a Run
151181

152182
Call `client.abort()` from another coroutine to cancel an in-flight query. The client sends a cancel message to the runtime and `query()` raises `CancelledException`.

packages/runtimeuse-client-python/README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ elif isinstance(result.data, StructuredOutputResult):
130130
print(result.metadata) # execution metadata
131131
```
132132

133+
### Command-Only Execution
134+
135+
Use `execute_commands()` when you need to run shell commands in the sandbox without invoking the agent. This is useful for setup steps, health checks, or any workflow where you only need command exit codes.
136+
137+
```python
138+
from runtimeuse_client import (
139+
CommandInterface,
140+
ExecuteCommandsOptions,
141+
RuntimeUseClient,
142+
)
143+
144+
client = RuntimeUseClient(ws_url="ws://localhost:8080")
145+
146+
result = await client.execute_commands(
147+
commands=[
148+
CommandInterface(command="mkdir -p /app/output"),
149+
CommandInterface(command="echo 'sandbox is ready' > /app/output/status.txt"),
150+
CommandInterface(command="cat /app/output/status.txt"),
151+
],
152+
options=ExecuteCommandsOptions(
153+
on_assistant_message=on_assistant, # optional -- streams stdout/stderr
154+
),
155+
)
156+
157+
for item in result.results:
158+
print(f"{item.command} -> exit code {item.exit_code}")
159+
```
160+
161+
`execute_commands()` supports the same streaming, cancellation, timeout, secret redaction, artifact upload, and error semantics as `query()`. If any command exits non-zero, `AgentRuntimeError` is raised.
162+
133163
### Artifact Upload Handshake
134164

135165
When the agent runtime requests an artifact upload, provide a callback that returns a presigned URL and content type. The client sends the response back automatically.
@@ -184,7 +214,10 @@ except CancelledException:
184214
| `ArtifactUploadRequestMessageInterface` | Runtime requesting a presigned URL for artifact upload |
185215
| `ArtifactUploadResponseMessageInterface` | Response with presigned URL sent back to runtime |
186216
| `ErrorMessageInterface` | Error from the agent runtime |
187-
| `CommandInterface` | Pre/post invocation shell command |
217+
| `ExecuteCommandsOptions` | Configuration for `client.execute_commands()` (callbacks, timeout) |
218+
| `CommandExecutionResult` | Return type of `execute_commands()` (`.results`) |
219+
| `CommandResultItem` | Per-command result (`.command`, `.exit_code`) |
220+
| `CommandInterface` | Shell command to execute (`.command`, `.cwd`) |
188221
| `RuntimeEnvironmentDownloadableInterface` | File to download into the runtime before invocation |
189222

190223
### Exceptions

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.7.0"
7+
version = "0.8.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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
RuntimeEnvironmentDownloadableInterface,
77
CommandInterface,
88
InvocationMessage,
9+
CommandExecutionMessage,
910
QueryOptions,
11+
ExecuteCommandsOptions,
1012
QueryResult,
13+
CommandResultItem,
14+
CommandExecutionResult,
1115
ResultMessageInterface,
16+
CommandExecutionResultMessageInterface,
1217
TextResult,
1318
StructuredOutputResult,
1419
AssistantMessageInterface,
@@ -31,9 +36,14 @@
3136
"RuntimeEnvironmentDownloadableInterface",
3237
"CommandInterface",
3338
"InvocationMessage",
39+
"CommandExecutionMessage",
3440
"QueryOptions",
41+
"ExecuteCommandsOptions",
3542
"QueryResult",
43+
"CommandResultItem",
44+
"CommandExecutionResult",
3645
"ResultMessageInterface",
46+
"CommandExecutionResultMessageInterface",
3747
"TextResult",
3848
"StructuredOutputResult",
3949
"AssistantMessageInterface",

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

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,20 @@
66
from .transports import Transport, WebSocketTransport
77
from .types import (
88
InvocationMessage,
9+
CommandExecutionMessage,
910
AgentRuntimeMessageInterface,
1011
CancelMessage,
1112
ErrorMessageInterface,
1213
ResultMessageInterface,
14+
CommandExecutionResultMessageInterface,
1315
QueryResult,
16+
CommandExecutionResult,
17+
CommandInterface,
1418
AssistantMessageInterface,
1519
ArtifactUploadRequestMessageInterface,
1620
ArtifactUploadResponseMessageInterface,
1721
QueryOptions,
22+
ExecuteCommandsOptions,
1823
)
1924
from .exceptions import AgentRuntimeError, CancelledException
2025

@@ -198,3 +203,144 @@ async def query(
198203
raise AgentRuntimeError("No result message received")
199204

200205
return QueryResult(data=wire_result.data, metadata=wire_result.metadata)
206+
207+
async def execute_commands(
208+
self,
209+
commands: list[CommandInterface],
210+
options: ExecuteCommandsOptions,
211+
) -> CommandExecutionResult:
212+
"""Execute commands in the runtime without invoking the agent.
213+
214+
Sends a :class:`CommandExecutionMessage`, processes the response
215+
stream, and returns a :class:`CommandExecutionResult` with
216+
per-command exit codes.
217+
218+
Args:
219+
commands: Commands to execute in the runtime environment.
220+
options: Execution configuration including secrets, callbacks,
221+
artifacts, and timeout.
222+
223+
Raises:
224+
AgentRuntimeError: If a command fails or the runtime sends an error.
225+
CancelledException: If cancelled via :meth:`abort`.
226+
TimeoutError: If the timeout is exceeded.
227+
"""
228+
logger = options.logger or _default_logger
229+
230+
self._abort_event = asyncio.Event()
231+
232+
message = CommandExecutionMessage(
233+
message_type="command_execution_message",
234+
source_id=options.source_id,
235+
secrets_to_redact=options.secrets_to_redact,
236+
commands=commands,
237+
artifacts_dir=options.artifacts_dir,
238+
pre_execution_downloadables=options.pre_execution_downloadables,
239+
)
240+
241+
send_queue: asyncio.Queue[dict] = asyncio.Queue()
242+
await send_queue.put(message.model_dump(mode="json"))
243+
244+
wire_result: CommandExecutionResultMessageInterface | None = None
245+
246+
async with asyncio.timeout(options.timeout):
247+
async for msg in self._transport(send_queue=send_queue):
248+
if self._abort_event.is_set():
249+
logger.info("Command execution cancelled by caller")
250+
await send_queue.put(
251+
CancelMessage(message_type="cancel_message").model_dump(
252+
mode="json"
253+
)
254+
)
255+
await send_queue.join()
256+
raise CancelledException("Command execution was cancelled")
257+
258+
try:
259+
message_interface = AgentRuntimeMessageInterface.model_validate(
260+
msg
261+
)
262+
except pydantic.ValidationError:
263+
logger.error(
264+
f"Received unknown message type from agent runtime: {msg}"
265+
)
266+
continue
267+
268+
if (
269+
message_interface.message_type
270+
== "command_execution_result_message"
271+
):
272+
wire_result = (
273+
CommandExecutionResultMessageInterface.model_validate(msg)
274+
)
275+
logger.info(
276+
f"Received command execution result from agent runtime: {msg}"
277+
)
278+
continue
279+
280+
elif message_interface.message_type == "assistant_message":
281+
if options.on_assistant_message is not None:
282+
assistant_message_interface = (
283+
AssistantMessageInterface.model_validate(msg)
284+
)
285+
await options.on_assistant_message(
286+
assistant_message_interface
287+
)
288+
continue
289+
290+
elif message_interface.message_type == "error_message":
291+
try:
292+
error_message_interface = (
293+
ErrorMessageInterface.model_validate(msg)
294+
)
295+
except pydantic.ValidationError:
296+
logger.error(
297+
f"Received malformed error message from agent runtime: {msg}",
298+
)
299+
raise AgentRuntimeError(str(msg))
300+
logger.error(
301+
f"Error from agent runtime: {error_message_interface}",
302+
)
303+
raise AgentRuntimeError(
304+
error_message_interface.error,
305+
metadata=error_message_interface.metadata,
306+
)
307+
308+
elif (
309+
message_interface.message_type
310+
== "artifact_upload_request_message"
311+
):
312+
logger.info(
313+
f"Received artifact upload request message from agent runtime: {msg}"
314+
)
315+
if options.on_artifact_upload_request is not None:
316+
artifact_upload_request_message_interface = (
317+
ArtifactUploadRequestMessageInterface.model_validate(
318+
msg
319+
)
320+
)
321+
upload_result = await options.on_artifact_upload_request(
322+
artifact_upload_request_message_interface
323+
)
324+
artifact_upload_response_message_interface = ArtifactUploadResponseMessageInterface(
325+
message_type="artifact_upload_response_message",
326+
filename=artifact_upload_request_message_interface.filename,
327+
filepath=artifact_upload_request_message_interface.filepath,
328+
presigned_url=upload_result.presigned_url,
329+
content_type=upload_result.content_type,
330+
)
331+
await send_queue.put(
332+
artifact_upload_response_message_interface.model_dump(
333+
mode="json"
334+
)
335+
)
336+
continue
337+
338+
else:
339+
logger.info(
340+
f"Received non-result message from agent runtime: {msg}"
341+
)
342+
343+
if wire_result is None:
344+
raise AgentRuntimeError("No result message received")
345+
346+
return CommandExecutionResult(results=wire_result.results)

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class AgentRuntimeMessageInterface(BaseModel):
1212
"assistant_message",
1313
"artifact_upload_request_message",
1414
"error_message",
15+
"command_execution_result_message",
1516
]
1617

1718

@@ -99,6 +100,33 @@ class CancelMessage(BaseModel):
99100
message_type: Literal["cancel_message"]
100101

101102

103+
class CommandExecutionMessage(BaseModel):
104+
message_type: Literal["command_execution_message"]
105+
source_id: str | None = None
106+
secrets_to_redact: list[str] = Field(default_factory=list)
107+
commands: list[CommandInterface]
108+
artifacts_dir: str | None = None
109+
pre_execution_downloadables: list[RuntimeEnvironmentDownloadableInterface] | None = None
110+
111+
112+
class CommandResultItem(BaseModel):
113+
command: str
114+
exit_code: int
115+
116+
117+
class CommandExecutionResult(BaseModel):
118+
"""Result returned by :meth:`RuntimeUseClient.execute_commands`."""
119+
120+
results: list[CommandResultItem]
121+
122+
123+
class CommandExecutionResultMessageInterface(AgentRuntimeMessageInterface):
124+
"""Wire-format result message from command-only execution."""
125+
126+
message_type: Literal["command_execution_result_message"]
127+
results: list[CommandResultItem]
128+
129+
102130
class ArtifactUploadResult(BaseModel):
103131
presigned_url: str
104132
content_type: str
@@ -156,3 +184,33 @@ def __post_init__(self) -> None:
156184
raise ValueError(
157185
"artifacts_dir and on_artifact_upload_request must be specified together"
158186
)
187+
188+
189+
@dataclass
190+
class ExecuteCommandsOptions:
191+
"""Options for :meth:`RuntimeUseClient.execute_commands`."""
192+
193+
#: Secret values to redact from command output.
194+
secrets_to_redact: list[str] = field(default_factory=list)
195+
#: Caller-defined identifier for tracing/logging purposes.
196+
source_id: str | None = None
197+
#: Directory inside the runtime environment where artifacts are written.
198+
artifacts_dir: str | None = None
199+
#: Files to download into the runtime environment before commands run.
200+
pre_execution_downloadables: list[RuntimeEnvironmentDownloadableInterface] | None = None
201+
#: Called for each assistant (intermediate) message streamed back.
202+
on_assistant_message: OnAssistantMessageCallback | None = None
203+
#: Called when the runtime requests an artifact upload URL.
204+
on_artifact_upload_request: OnArtifactUploadRequestCallback | None = None
205+
#: Overall timeout in seconds. ``None`` means no limit.
206+
timeout: float | None = None
207+
#: Logger instance; falls back to the module-level logger when ``None``.
208+
logger: logging.Logger | None = None
209+
210+
def __post_init__(self) -> None:
211+
has_dir = self.artifacts_dir is not None
212+
has_cb = self.on_artifact_upload_request is not None
213+
if has_dir != has_cb:
214+
raise ValueError(
215+
"artifacts_dir and on_artifact_upload_request must be specified together"
216+
)

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import dotenv
55
import pytest
66

7-
from src.runtimeuse_client import RuntimeUseClient, QueryOptions
7+
from src.runtimeuse_client import RuntimeUseClient, QueryOptions, ExecuteCommandsOptions
88

99
dotenv.load_dotenv()
1010

@@ -75,3 +75,13 @@ def query_options():
7575
def make_query_options():
7676
"""Return the _make_query_options factory for tests that need custom fields."""
7777
return _make_query_options
78+
79+
80+
def _make_execute_commands_options(**overrides: Any) -> ExecuteCommandsOptions:
81+
return ExecuteCommandsOptions(**overrides)
82+
83+
84+
@pytest.fixture
85+
def make_execute_commands_options():
86+
"""Return the _make_execute_commands_options factory for tests."""
87+
return _make_execute_commands_options

0 commit comments

Comments
 (0)