Skip to content

Commit b48dc46

Browse files
committed
sdk: enhance arun collect_output with file size info (#84)
- fix: f-string bug in _build_nohup_detached_message where tmp_file was not interpolated - feat: add file size info when collect_output=False to help users decide how to read large output files - test: update unit tests to handle new stat command for file size retrieval - test: enhance integration test to verify file size info in output - docs: update sandbox SDK reference docs (EN & ZH) with collect_output examples - fix: disable bash history expansion in local_sandbox to preserve PID markers
1 parent d320289 commit b48dc46

File tree

7 files changed

+206
-23
lines changed

7 files changed

+206
-23
lines changed

docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/References/Python SDK References/sandbox.md

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,53 @@
11
# Sandbox SDK 参考
22

33
## `arun`
4-
`arun()` 方法新增 `response_limited_bytes_in_nohup` 参数(int型),解决response过长导致的请求超时问题。
5-
该参数用于限制 nohup 模式下返回的 output 字符数,默认值为 `None`,表示不限制。
4+
`arun()``nohup` 模式下提供了两个关键参数,帮助 Agent / 调用方在“执行”与“查看”之间按需解耦:
5+
6+
1. **`response_limited_bytes_in_nohup`**(int 型)
7+
限制返回内容的最大字符数(例如 `64 * 1024`),适合仍需立刻查看部分日志、但必须控制带宽的场景。默认值 `None` 表示不加限制。
8+
9+
2. **`collect_output`**(bool,默认 `True`
10+
当设为 `False` 时,`arun()` 不再读取 nohup 输出文件,而是在命令执行完毕后立即返回一段提示信息(包含输出文件路径及查看方式)。日志仍写入 `/tmp/tmp_<timestamp>.out`,后续可通过 `read_file`、下载接口或自定义命令按需读取,实现“执行”与“查看”彻底解耦。
611

712
```python
813
from rock.sdk.sandbox.client import Sandbox
914
from rock.sdk.sandbox.config import SandboxConfig
1015
from rock.sdk.sandbox.request import CreateBashSessionRequest
1116

1217
config = SandboxConfig(
13-
image=f"{image}",
14-
xrl_authorization=f"{xrl_authorization}",
15-
user_id=f"{user_id}",
16-
cluster=f"{cluster}",
17-
)
18+
image=f"{image}",
19+
xrl_authorization=f"{xrl_authorization}",
20+
user_id=f"{user_id}",
21+
cluster=f"{cluster}",
22+
)
1823
sandbox = Sandbox(config)
1924

20-
session = sandbox.create_session(
21-
CreateBashSessionRequest(
25+
session = sandbox.create_session(CreateBashSessionRequest(session="bash-1"))
26+
27+
# 示例 1:限制最多 1024 个字符
28+
resp_limit = asyncio.run(
29+
sandbox.arun(
30+
cmd="cat /tmp/test.txt",
31+
mode="nohup",
2232
session="bash-1",
23-
response_limited_bytes_in_nohup=1024
33+
response_limited_bytes_in_nohup=1024,
2434
)
2535
)
2636

27-
# 返回的数据最多只有1024个字符
28-
resp = asyncio.run(
37+
# 示例 2:完全跳过日志读取,后续再通过 read_file / 下载获取
38+
resp_detached = asyncio.run(
2939
sandbox.arun(
30-
cmd="cat /tmp/test.txt",
40+
cmd="bash run_long_job.sh",
3141
mode="nohup",
3242
session="bash-1",
43+
collect_output=False,
3344
)
3445
)
46+
print(resp_detached.output)
47+
# Command executed in nohup mode without streaming the log content.
48+
# Status: completed
49+
# Output file: /tmp/tmp_xxx.out
50+
# 可通过 Sandbox.read_file(...) / 下载接口 / cat /tmp/tmp_xxx.out 查看日志
3551
```
3652

3753
## `read_file_by_line_range`

docs/rock/References/Python SDK References/sandbox.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
## `arun`
44

5-
The `arun()` method now supports a new parameter: `response_limited_bytes_in_nohup` (integer type), which resolves request timeout issues caused by excessively long responses.
6-
This parameter limits the number of characters returned in the output when running in `nohup` mode. The default value is `None`, meaning no limit is applied.
5+
`arun()` provides two knobs to control how `nohup` output is handled:
6+
7+
1. **`response_limited_bytes_in_nohup`** *(integer type)*
8+
Caps the number of characters returned from the nohup output file. Useful when you still need to stream some logs back but want an upper bound (default `None` = no cap).
9+
10+
2. **`collect_output`** *(bool, default `True`)*
11+
When set to `False`, `arun()` skips reading the nohup output file entirely. The command still runs to completion and writes logs to `/tmp/tmp_<timestamp>.out`, but the SDK immediately returns a lightweight hint telling agents where to fetch the logs later (via `read_file`, download APIs, or custom commands). This fully decouples “execute command” from “inspect logs”.
712

813
```python
914
from rock.sdk.sandbox.client import Sandbox
@@ -18,21 +23,32 @@ config = SandboxConfig(
1823
)
1924
sandbox = Sandbox(config)
2025

21-
session = sandbox.create_session(
22-
CreateBashSessionRequest(
26+
session = sandbox.create_session(CreateBashSessionRequest(session="bash-1"))
27+
28+
# Example 1: limit the returned logs to 1024 characters
29+
resp_limited = asyncio.run(
30+
sandbox.arun(
31+
cmd="cat /tmp/test.txt",
32+
mode="nohup",
2333
session="bash-1",
24-
response_limited_bytes_in_nohup=1024
34+
response_limited_bytes_in_nohup=1024,
2535
)
2636
)
2737

28-
# The returned response will contain at most 1024 characters
29-
resp = asyncio.run(
38+
# Example 2: skip collecting logs; agent will download/read them later
39+
resp_detached = asyncio.run(
3040
sandbox.arun(
31-
cmd="cat /tmp/test.txt",
41+
cmd="bash run_long_job.sh",
3242
mode="nohup",
3343
session="bash-1",
44+
collect_output=False,
3445
)
3546
)
47+
print(resp_detached.output)
48+
# Command executed in nohup mode without streaming the log content.
49+
# Status: completed
50+
# Output file: /tmp/tmp_xxx.out
51+
# Use Sandbox.read_file(...), download APIs, or run 'cat /tmp/tmp_xxx.out' ...
3652
```
3753

3854
## `read_file_by_line_range`

rock/rocklet/local_sandbox.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ def shell(self) -> pexpect.spawn:
163163
def _get_reset_commands(self) -> list[str]:
164164
"""Commands to reset the PS1, PS2, and PS0 variables to their default values."""
165165
return [
166+
# Disable bash history expansion so literals like PIDSTART$!PIDEND remain intact.
167+
"set +H",
166168
"unset PROMPT_COMMAND",
167169
f"export PS1='{self._ps1}'",
168170
"export PS2=''",

rock/sdk/sandbox/client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ async def arun(
308308
wait_interval=10,
309309
mode: RunModeType = "normal",
310310
response_limited_bytes_in_nohup: int | None = None,
311+
collect_output: bool = True,
311312
) -> Observation:
312313
if mode == "nohup":
313314
try:
@@ -338,6 +339,24 @@ async def arun(
338339
success, message = await self._wait_for_process_completion(
339340
pid=pid, session=session, wait_timeout=wait_timeout, wait_interval=wait_interval
340341
)
342+
343+
if not collect_output:
344+
# Get file size to help user decide how to read it
345+
file_size = None
346+
try:
347+
size_result: Observation = await self._run_in_session(
348+
BashAction(session=session, command=f"stat -c %s {tmp_file} 2>/dev/null || stat -f %z {tmp_file}")
349+
)
350+
if size_result.exit_code == 0 and size_result.output.strip().isdigit():
351+
file_size = int(size_result.output.strip())
352+
except Exception as e:
353+
# Log the error for debugging, but don't fail the main flow
354+
logger.warning(f"Failed to get file size for {tmp_file}: {e}")
355+
detached_msg = self._build_nohup_detached_message(tmp_file, success, message, file_size)
356+
if success:
357+
return Observation(output=detached_msg, exit_code=0)
358+
return Observation(output=detached_msg, exit_code=1, failure_reason=message)
359+
341360
check_res_command = f"cat {tmp_file}"
342361
if response_limited_bytes_in_nohup:
343362
check_res_command = f"head -c {response_limited_bytes_in_nohup} {tmp_file}"
@@ -431,6 +450,30 @@ async def _wait_for_process_completion(
431450
timeout_msg = f"Process {pid} did not complete within {elapsed:.1f}s (timeout: {wait_timeout}s)"
432451
return False, timeout_msg
433452

453+
def _build_nohup_detached_message(
454+
self, tmp_file: str, success: bool, detail: str | None, file_size: int | None = None
455+
) -> str:
456+
status = "completed" if success else "finished with errors"
457+
lines = [
458+
"Command executed in nohup mode without streaming the log content.",
459+
f"Status: {status}",
460+
f"Output file: {tmp_file}",
461+
]
462+
if file_size is not None:
463+
if file_size < 1024:
464+
size_str = f"{file_size} bytes"
465+
elif file_size < 1024 * 1024:
466+
size_str = f"{file_size / 1024:.2f} KB"
467+
else:
468+
size_str = f"{file_size / (1024 * 1024):.2f} MB"
469+
lines.append(f"File size: {size_str}")
470+
lines.append(
471+
f"Use Sandbox.read_file(...), download APIs, or run 'cat {tmp_file}' inside the session to inspect the result."
472+
)
473+
if detail:
474+
lines.append(f"Detail: {detail}")
475+
return "\n".join(lines)
476+
434477
async def upload(self, request: UploadRequest) -> UploadResponse:
435478
return await self.upload_by_path(file_path=request.source_path, target_path=request.target_path)
436479

tests/integration/sdk/sandbox/test_sdk_client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,25 @@ async def test_arun_nohup(sandbox_instance: Sandbox):
1616
print(resp.output)
1717
nohup_test_resp = await sandbox_instance.arun(session="default", cmd="cat /tmp/nohup_test.txt")
1818
assert "import os" in nohup_test_resp.output
19-
await sandbox_instance.arun(session="default", cmd="rm -rf /tmp/nohup_test.txt")
2019

20+
detached_resp = await sandbox_instance.arun(
21+
session="default",
22+
cmd="/bin/bash -c 'echo detached-output'",
23+
mode="nohup",
24+
collect_output=False,
25+
)
26+
output_line = next((line for line in detached_resp.output.splitlines() if line.startswith("Output file:")), None)
27+
assert output_line is not None
28+
output_file = output_line.split(":", 1)[1].strip()
29+
assert "without streaming the log content" in detached_resp.output
30+
# Verify file size is included in output
31+
assert "File size:" in detached_resp.output
32+
33+
file_content_resp = await sandbox_instance.arun(session="default", cmd=f"cat {output_file}")
34+
assert "detached-output" in file_content_resp.output
35+
await sandbox_instance.arun(session="default", cmd=f"rm -f {output_file}")
36+
37+
await sandbox_instance.arun(session="default", cmd="rm -rf /tmp/nohup_test.txt")
2138

2239
@pytest.mark.need_admin
2340
@SKIP_IF_NO_DOCKER

tests/unit/sdk/test_arun_nohup.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import types
2+
3+
import pytest
4+
5+
from rock.actions.sandbox.response import Observation
6+
from rock.sdk.common.constants import PID_PREFIX, PID_SUFFIX
7+
from rock.sdk.sandbox.client import Sandbox
8+
from rock.sdk.sandbox.config import SandboxConfig
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_arun_nohup_collect_output_false_returns_hint(monkeypatch):
13+
timestamp = 1701
14+
monkeypatch.setattr("rock.sdk.sandbox.client.time.time_ns", lambda: timestamp)
15+
sandbox = Sandbox(SandboxConfig(image="mock-image"))
16+
17+
executed_commands: list[str] = []
18+
19+
async def fake_run_in_session(self, action):
20+
executed_commands.append(action.command)
21+
if action.command.startswith("nohup "):
22+
return Observation(output=f"{PID_PREFIX}12345{PID_SUFFIX}", exit_code=0)
23+
if action.command.startswith("stat "):
24+
# Return a mock file size of 2048 bytes
25+
return Observation(output="2048", exit_code=0)
26+
raise AssertionError(f"Unexpected command executed: {action.command}")
27+
28+
sandbox._run_in_session = types.MethodType(fake_run_in_session, sandbox) # type: ignore
29+
30+
async def fake_wait(self, pid, session, wait_timeout, wait_interval):
31+
return True, "Process completed successfully in 1.0s"
32+
33+
monkeypatch.setattr(Sandbox, "_wait_for_process_completion", fake_wait)
34+
35+
result = await sandbox.arun(
36+
cmd="echo detached",
37+
session="bash-detached",
38+
mode="nohup",
39+
collect_output=False,
40+
)
41+
42+
assert result.exit_code == 0
43+
assert result.failure_reason == ""
44+
assert "/tmp/tmp_1701.out" in result.output
45+
assert "without streaming the log content" in result.output
46+
assert "File size: 2.00 KB" in result.output
47+
assert len(executed_commands) == 2
48+
assert executed_commands[0].startswith("nohup ")
49+
assert executed_commands[1].startswith("stat ")
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_arun_nohup_collect_output_false_propagates_failure(monkeypatch):
54+
timestamp = 1802
55+
monkeypatch.setattr("rock.sdk.sandbox.client.time.time_ns", lambda: timestamp)
56+
sandbox = Sandbox(SandboxConfig(image="mock-image"))
57+
58+
executed_commands: list[str] = []
59+
60+
async def fake_run_in_session(self, action):
61+
executed_commands.append(action.command)
62+
if action.command.startswith("nohup "):
63+
return Observation(output=f"{PID_PREFIX}999{PID_SUFFIX}", exit_code=0)
64+
if action.command.startswith("stat "):
65+
# Return a mock file size of 512 bytes
66+
return Observation(output="512", exit_code=0)
67+
raise AssertionError("Unexpected command execution when collect_output=False")
68+
69+
sandbox._run_in_session = types.MethodType(fake_run_in_session, sandbox) # type: ignore
70+
71+
async def fake_wait(self, pid, session, wait_timeout, wait_interval):
72+
return False, "Process timed out"
73+
74+
monkeypatch.setattr(Sandbox, "_wait_for_process_completion", fake_wait)
75+
76+
result = await sandbox.arun(
77+
cmd="sleep 999",
78+
session="bash-detached",
79+
mode="nohup",
80+
collect_output=False,
81+
)
82+
83+
assert result.exit_code == 1
84+
assert result.failure_reason == "Process timed out"
85+
assert "Process timed out" in result.output
86+
assert "/tmp/tmp_1802.out" in result.output
87+
assert "File size: 512 bytes" in result.output
88+
assert len(executed_commands) == 2
89+

tests/unit/utils/test_shell_util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async def test_top_command():
124124
cmd = "export TERM=xterm && top"
125125
async for pid, output in mock_arun(cmd):
126126
assert pid
127-
assert output.__contains__("failed tty get")
127+
assert "failed tty get" in output or "Processes:" in output
128128

129129

130130
@pytest.mark.asyncio

0 commit comments

Comments
 (0)