Skip to content

Commit c4aa7f1

Browse files
authored
Merge pull request #20 from getlark/jb-command-env
add `env` param to command type
2 parents 1f0e720 + 7d9fba3 commit c4aa7f1

12 files changed

Lines changed: 297 additions & 93 deletions

File tree

packages/runtimeuse-client-python/README.md

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ result = await client.query(
114114
options=QueryOptions(
115115
system_prompt="You are a helpful assistant.",
116116
model="gpt-4.1",
117+
agent_env={"MY_VAR": "value"}, # optional -- env vars for the agent
117118
pre_agent_downloadables=[downloadable], # optional
118119
output_format_json_schema_str='...', # optional -- omit for text response
119120
on_assistant_message=on_assistant, # optional
@@ -147,7 +148,7 @@ result = await client.execute_commands(
147148
commands=[
148149
CommandInterface(command="mkdir -p /app/output"),
149150
CommandInterface(command="echo 'sandbox is ready' > /app/output/status.txt"),
150-
CommandInterface(command="cat /app/output/status.txt"),
151+
CommandInterface(command="cat /app/output/status.txt", env={"MY_VAR": "value"}),
151152
],
152153
options=ExecuteCommandsOptions(
153154
on_assistant_message=on_assistant, # optional -- streams stdout/stderr
@@ -203,22 +204,22 @@ except CancelledException:
203204

204205
### Types
205206

206-
| Class | Description |
207-
| ----------------------------------------- | ------------------------------------------------------------------------ |
208-
| `QueryOptions` | Configuration for `client.query()` (prompt options, callbacks, timeout) |
209-
| `QueryResult` | Return type of `query()` (`.data`, `.metadata`) |
210-
| `ResultMessageInterface` | Wire-format result message from the runtime |
211-
| `TextResult` | Result variant when no output schema is specified (`.text`) |
212-
| `StructuredOutputResult` | Result variant when an output schema is specified (`.structured_output`) |
213-
| `AssistantMessageInterface` | Intermediate assistant text messages |
214-
| `ArtifactUploadRequestMessageInterface` | Runtime requesting a presigned URL for artifact upload |
215-
| `ArtifactUploadResponseMessageInterface` | Response with presigned URL sent back to runtime |
216-
| `ErrorMessageInterface` | Error from the agent runtime |
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`) |
221-
| `RuntimeEnvironmentDownloadableInterface` | File to download into the runtime before invocation |
207+
| Class | Description |
208+
| ----------------------------------------- | ------------------------------------------------------------------------------------ |
209+
| `QueryOptions` | Configuration for `client.query()` (prompt options, `agent_env`, callbacks, timeout) |
210+
| `QueryResult` | Return type of `query()` (`.data`, `.metadata`) |
211+
| `ResultMessageInterface` | Wire-format result message from the runtime |
212+
| `TextResult` | Result variant when no output schema is specified (`.text`) |
213+
| `StructuredOutputResult` | Result variant when an output schema is specified (`.structured_output`) |
214+
| `AssistantMessageInterface` | Intermediate assistant text messages |
215+
| `ArtifactUploadRequestMessageInterface` | Runtime requesting a presigned URL for artifact upload |
216+
| `ArtifactUploadResponseMessageInterface` | Response with presigned URL sent back to runtime |
217+
| `ErrorMessageInterface` | Error from the agent runtime |
218+
| `ExecuteCommandsOptions` | Configuration for `client.execute_commands()` (callbacks, timeout) |
219+
| `CommandExecutionResult` | Return type of `execute_commands()` (`.results`) |
220+
| `CommandResultItem` | Per-command result (`.command`, `.exit_code`) |
221+
| `CommandInterface` | Shell command to execute (`.command`, `.cwd`, `.env`) |
222+
| `RuntimeEnvironmentDownloadableInterface` | File to download into the runtime before invocation |
222223

223224
### Exceptions
224225

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

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ async def query(
9898
model=options.model,
9999
output_format_json_schema_str=options.output_format_json_schema_str,
100100
source_id=options.source_id,
101+
agent_env=options.agent_env,
101102
secrets_to_redact=options.secrets_to_redact,
102103
artifacts_dir=options.artifacts_dir,
103104
pre_agent_invocation_commands=options.pre_agent_invocation_commands,
@@ -256,21 +257,16 @@ async def execute_commands(
256257
raise CancelledException("Command execution was cancelled")
257258

258259
try:
259-
message_interface = AgentRuntimeMessageInterface.model_validate(
260-
msg
261-
)
260+
message_interface = AgentRuntimeMessageInterface.model_validate(msg)
262261
except pydantic.ValidationError:
263262
logger.error(
264263
f"Received unknown message type from agent runtime: {msg}"
265264
)
266265
continue
267266

268-
if (
269-
message_interface.message_type
270-
== "command_execution_result_message"
271-
):
272-
wire_result = (
273-
CommandExecutionResultMessageInterface.model_validate(msg)
267+
if message_interface.message_type == "command_execution_result_message":
268+
wire_result = CommandExecutionResultMessageInterface.model_validate(
269+
msg
274270
)
275271
logger.info(
276272
f"Received command execution result from agent runtime: {msg}"
@@ -282,15 +278,13 @@ async def execute_commands(
282278
assistant_message_interface = (
283279
AssistantMessageInterface.model_validate(msg)
284280
)
285-
await options.on_assistant_message(
286-
assistant_message_interface
287-
)
281+
await options.on_assistant_message(assistant_message_interface)
288282
continue
289283

290284
elif message_interface.message_type == "error_message":
291285
try:
292-
error_message_interface = (
293-
ErrorMessageInterface.model_validate(msg)
286+
error_message_interface = ErrorMessageInterface.model_validate(
287+
msg
294288
)
295289
except pydantic.ValidationError:
296290
logger.error(
@@ -306,17 +300,14 @@ async def execute_commands(
306300
)
307301

308302
elif (
309-
message_interface.message_type
310-
== "artifact_upload_request_message"
303+
message_interface.message_type == "artifact_upload_request_message"
311304
):
312305
logger.info(
313306
f"Received artifact upload request message from agent runtime: {msg}"
314307
)
315308
if options.on_artifact_upload_request is not None:
316309
artifact_upload_request_message_interface = (
317-
ArtifactUploadRequestMessageInterface.model_validate(
318-
msg
319-
)
310+
ArtifactUploadRequestMessageInterface.model_validate(msg)
320311
)
321312
upload_result = await options.on_artifact_upload_request(
322313
artifact_upload_request_message_interface

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ class CommandInterface(BaseModel):
2626

2727
cwd: str | None = None
2828
command: str
29+
env: dict[str, str] | None = None
2930

3031

3132
class InvocationMessage(BaseModel):
3233
message_type: Literal["invocation_message"]
3334
source_id: str | None = None
3435
system_prompt: str
3536
user_prompt: str
37+
agent_env: dict[str, str] | None = None
3638
output_format_json_schema_str: str | None = None
3739
secrets_to_redact: list[str] = Field(default_factory=list)
3840
artifacts_dir: str | None = None
@@ -106,7 +108,9 @@ class CommandExecutionMessage(BaseModel):
106108
secrets_to_redact: list[str] = Field(default_factory=list)
107109
commands: list[CommandInterface]
108110
artifacts_dir: str | None = None
109-
pre_execution_downloadables: list[RuntimeEnvironmentDownloadableInterface] | None = None
111+
pre_execution_downloadables: (
112+
list[RuntimeEnvironmentDownloadableInterface] | None
113+
) = None
110114

111115

112116
class CommandResultItem(BaseModel):
@@ -157,6 +161,8 @@ class QueryOptions:
157161

158162
#: Caller-defined identifier for tracing/logging purposes.
159163
source_id: str | None = None
164+
#: Environment variables to set in the agent runtime.
165+
agent_env: dict[str, str] | None = None
160166
#: Secret values to redact from agent logs and responses.
161167
secrets_to_redact: list[str] = field(default_factory=list)
162168
#: Directory inside the runtime environment where artifacts are written.
@@ -197,7 +203,9 @@ class ExecuteCommandsOptions:
197203
#: Directory inside the runtime environment where artifacts are written.
198204
artifacts_dir: str | None = None
199205
#: Files to download into the runtime environment before commands run.
200-
pre_execution_downloadables: list[RuntimeEnvironmentDownloadableInterface] | None = None
206+
pre_execution_downloadables: (
207+
list[RuntimeEnvironmentDownloadableInterface] | None
208+
) = None
201209
#: Called for each assistant (intermediate) message streamed back.
202210
on_assistant_message: OnAssistantMessageCallback | None = None
203211
#: Called when the runtime requests an artifact upload URL.

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

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ class TestResultMessage:
4545
async def test_structured_output_result(self, fake_transport, make_query_options):
4646
result_msg = {
4747
"message_type": "result_message",
48-
"data": {"type": "structured_output", "structured_output": {"success": True}},
48+
"data": {
49+
"type": "structured_output",
50+
"structured_output": {"success": True},
51+
},
4952
"metadata": {"duration_ms": 50},
5053
}
5154
transport, client = fake_transport([result_msg])
@@ -363,7 +366,10 @@ async def test_full_message_sequence(self, fake_transport, make_query_options):
363366
},
364367
{
365368
"message_type": "result_message",
366-
"data": {"type": "structured_output", "structured_output": {"answer": 42}},
369+
"data": {
370+
"type": "structured_output",
371+
"structured_output": {"answer": 42},
372+
},
367373
"metadata": {"duration_ms": 100},
368374
},
369375
]
@@ -421,6 +427,36 @@ async def test_schema_forwarded_when_set(self, fake_transport, make_query_option
421427
invocation_msgs[0]["output_format_json_schema_str"] == '{"type":"object"}'
422428
)
423429

430+
@pytest.mark.asyncio
431+
async def test_agent_env_forwarded_when_set(
432+
self, fake_transport, make_query_options
433+
):
434+
transport, client = fake_transport([TEXT_RESULT_MSG])
435+
436+
await client.query(
437+
prompt=DEFAULT_PROMPT,
438+
options=make_query_options(agent_env={"MY_VAR": "hello"}),
439+
)
440+
441+
invocation_msgs = [
442+
m for m in transport.sent if m.get("message_type") == "invocation_message"
443+
]
444+
assert invocation_msgs[0]["agent_env"] == {"MY_VAR": "hello"}
445+
446+
@pytest.mark.asyncio
447+
async def test_agent_env_none_when_omitted(self, fake_transport, query_options):
448+
transport, client = fake_transport([TEXT_RESULT_MSG])
449+
450+
await client.query(
451+
prompt=DEFAULT_PROMPT,
452+
options=query_options,
453+
)
454+
455+
invocation_msgs = [
456+
m for m in transport.sent if m.get("message_type") == "invocation_message"
457+
]
458+
assert invocation_msgs[0]["agent_env"] is None
459+
424460
@pytest.mark.asyncio
425461
async def test_schema_none_when_omitted(self, fake_transport, query_options):
426462
transport, client = fake_transport([TEXT_RESULT_MSG])
@@ -518,7 +554,9 @@ async def test_sends_command_execution_message(
518554
]
519555
assert len(cmd_msgs) == 1
520556
assert cmd_msgs[0]["source_id"] == "cmd-test"
521-
assert cmd_msgs[0]["commands"] == [{"command": "echo hello", "cwd": None}]
557+
assert cmd_msgs[0]["commands"] == [
558+
{"command": "echo hello", "cwd": None, "env": None}
559+
]
522560

523561
@pytest.mark.asyncio
524562
async def test_assistant_message_dispatched(
@@ -641,6 +679,49 @@ async def on_artifact(
641679
assert response_msgs[0]["filename"] == "output.txt"
642680
assert response_msgs[0]["presigned_url"] == "https://s3.example.com/presigned"
643681

682+
@pytest.mark.asyncio
683+
async def test_command_env_forwarded(
684+
self, fake_transport, make_execute_commands_options
685+
):
686+
result_msg = {
687+
"message_type": "command_execution_result_message",
688+
"results": [{"command": "echo hello", "exit_code": 0}],
689+
}
690+
transport, client = fake_transport([result_msg])
691+
692+
await client.execute_commands(
693+
commands=[CommandInterface(command="echo hello", env={"FOO": "bar"})],
694+
options=make_execute_commands_options(),
695+
)
696+
697+
cmd_msgs = [
698+
m
699+
for m in transport.sent
700+
if m.get("message_type") == "command_execution_message"
701+
]
702+
assert len(cmd_msgs) == 1
703+
assert cmd_msgs[0]["commands"] == [
704+
{"command": "echo hello", "cwd": None, "env": {"FOO": "bar"}}
705+
]
706+
707+
@pytest.mark.asyncio
708+
async def test_command_env_none_by_default(
709+
self, fake_transport, make_execute_commands_options
710+
):
711+
transport, client = fake_transport([COMMAND_RESULT_MSG])
712+
713+
await client.execute_commands(
714+
commands=[CommandInterface(command="echo hello")],
715+
options=make_execute_commands_options(),
716+
)
717+
718+
cmd_msgs = [
719+
m
720+
for m in transport.sent
721+
if m.get("message_type") == "command_execution_message"
722+
]
723+
assert cmd_msgs[0]["commands"][0]["env"] is None
724+
644725
def test_execute_commands_options_artifacts_validation(self):
645726
with pytest.raises(ValueError, match="must be specified together"):
646727
ExecuteCommandsOptions(artifacts_dir="/tmp/artifacts")

0 commit comments

Comments
 (0)