Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 65 additions & 14 deletions crates/execution/assets/runners/python-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2345,17 +2345,20 @@ del _agentos_install_bundled_finder
bundledFiles.has(lockfilePackages[normalizePythonPackageName(distributionName)]?.file_name);
}

// Filename for runner-owned Python source, so tracebacks can drop its frames.
const PYTHON_INTERNAL_FILENAME = '<agentos>';

// `pip` / `python -m pip`: emulate the common pip CLI via Pyodide's micropip,
// which fetches wheels through the runner's kernel-backed fetch (so egress is
// governed by the VM network policy, never an ambient host fetch). Installed
// packages are copied into the persistent VFS site-packages so they survive the
// per-process interpreter and can be imported by a later `python` invocation.
// Bundled packages are importable in every process, so they are not copied.
async function runPythonPip(pyodide, isBundledPackage) {
async function runPythonPip(pyodide, runMain, isBundledPackage) {
pyodide.globals.set('__agentos_vfs_site', PYTHON_VFS_SITE_PACKAGES);
pyodide.globals.set('__agentos_is_bundled_package', isBundledPackage);
try {
await pyodide.runPythonAsync(`
return await runMain(`
import importlib.metadata, os, shutil, site, sys
_agentos_pip_args = sys.argv[1:]
if _agentos_pip_args and _agentos_pip_args[0] == "install":
Expand Down Expand Up @@ -2398,7 +2401,7 @@ elif _agentos_pip_args and _agentos_pip_args[0] == "list":
else:
print("usage: pip install <package> [<package> ...]", file=sys.stderr)
sys.exit(2)
`);
`, PYTHON_INTERNAL_FILENAME);
} finally {
try {
pyodide.globals.delete('__agentos_vfs_site');
Expand All @@ -2414,16 +2417,59 @@ else:

// Minimal interactive REPL backed by the kernel stdin stream (sys.stdin via
// setStdin). Prompts use the standard PS1/PS2; EOF on stdin ends the session.
async function runPythonRepl(pyodide) {
await pyodide.runPythonAsync(`
function runPythonRepl(runMain) {
return runMain(`
import sys
from code import InteractiveConsole
if not hasattr(sys, "ps1"):
sys.ps1 = ">>> "
if not hasattr(sys, "ps2"):
sys.ps2 = "... "
InteractiveConsole(locals={"__name__": "__main__", "__doc__": None}).interact(banner="", exitmsg="")
`);
`, PYTHON_INTERNAL_FILENAME);
}

// Returns `runMain(source, filename)`, which runs a main program in `__main__`
// and resolves to its process exit status the way CPython decides it:
// `SystemExit` maps to its code, and any other uncaught exception prints a
// Python traceback through `sys.excepthook` and exits 1. Without this,
// `SystemExit` escapes Pyodide's event loop as an uncaught JS error.
function installPythonMainRunner(pyodide) {
pyodide.runPython(
`
async def _agentos_run_main(source, filename):
import __main__, sys
from _pyodide._base import __file__ as pyodide_base_file, eval_code_async

try:
await eval_code_async(source, __main__.__dict__, filename=filename)
return 0
except SystemExit as error:
code = error.code
if code is None:
return 0
if isinstance(code, int):
return code & 0xFF
print(code, file=sys.stderr)
return 1
except BaseException as error:
tb = error.__traceback__
while tb is not None and tb.tb_frame.f_code.co_filename in (
${JSON.stringify(PYTHON_INTERNAL_FILENAME)},
pyodide_base_file,
):
tb = tb.tb_next
sys.excepthook(type(error), error, tb)
return 1
finally:
sys.stdout.flush()
sys.stderr.flush()
`,
{ filename: PYTHON_INTERNAL_FILENAME },
);
const runMain = pyodide.globals.get('_agentos_run_main');
pyodide.globals.delete('_agentos_run_main');
return runMain;
}

function resolvePythonSource(pyodide) {
Expand Down Expand Up @@ -2512,9 +2558,6 @@ try {
error,
);
});
if (retainLanguageContext) {
globalThis.__agentOSRetainedPyodide = pyodide;
}
restorePyodideShellCompat();
emitWarmupStage('after-load-pyodide');
const loadPyodideMs = realPerformance.now() - loadPyodideStarted;
Expand Down Expand Up @@ -2553,23 +2596,31 @@ try {
loadPyodideMs,
source,
});
const runMain = installPythonMainRunner(pyodide);
if (retainLanguageContext) {
globalThis.__agentOSRunRetainedPython = (code) => runMain(code, '<string>');
}
if (moduleName === 'pip') {
await runPythonPip(pyodide, isBundledPackage);
process.exitCode = await runPythonPip(pyodide, runMain, isBundledPackage);
} else if (moduleName) {
pyodide.globals.set('__agentos_module', moduleName);
try {
await pyodide.runPythonAsync(
process.exitCode = await runMain(
'import runpy\nrunpy.run_module(__agentos_module, run_name="__main__", alter_sys=True)',
PYTHON_INTERNAL_FILENAME,
);
} finally {
pyodide.globals.delete('__agentos_module');
}
} else if (stdinProgram) {
await pyodide.runPythonAsync(readProgramFromStdin());
process.exitCode = await runMain(readProgramFromStdin(), '<stdin>');
} else if (interactive) {
await runPythonRepl(pyodide);
process.exitCode = await runPythonRepl(runMain);
} else {
await pyodide.runPythonAsync(resolvePythonSource(pyodide));
process.exitCode = await runMain(
resolvePythonSource(pyodide),
readRunnerEnv(PYTHON_FILE_ENV) ?? '<string>',
);
}
}
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion crates/execution/src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ impl PythonExecution {
)))
})?;
let runner = format!(
"process.exitCode = 0; (async () => {{ const pyodide = globalThis.__agentOSRetainedPyodide; if (!pyodide) throw new Error('retained Python interpreter is unavailable'); await pyodide.runPythonAsync({source}); }})()"
"process.exitCode = 0; (async () => {{ const runPython = globalThis.__agentOSRunRetainedPython; if (!runPython) throw new Error('retained Python interpreter is unavailable'); process.exitCode = await runPython({source}); }})()"
);
self.inner
.execute_retained(runner, String::from("/[agentos-python-retained]"), false)
Expand Down
52 changes: 52 additions & 0 deletions crates/native-sidecar/tests/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3564,6 +3564,56 @@ fn python_command_runs_inline_code() {
assert_eq!(stdout, "2\n", "stderr: {stderr}");
}

fn python_command_exits_with_cpython_status_and_traceback() {
assert_node_available();
let mut sidecar = new_sidecar("python-cli-exit-status");
let cwd = temp_dir("python-cli-exit-status-cwd");
let connection_id = authenticate_wire(&mut sidecar, "conn-python");
let session_id = open_session_wire(&mut sidecar, 2, &connection_id);
let (vm_id, _) = create_vm_wire(
&mut sidecar,
3,
&connection_id,
&session_id,
GuestRuntimeKind::Python,
&cwd,
);

let cases = [
("raise SystemExit(7)", 7, ""),
("import sys; sys.exit(0)", 0, ""),
("import sys; sys.exit('msg')", 1, "msg\n"),
(
"raise ValueError('boom')",
1,
"Traceback (most recent call last):\n File \"<string>\", line 1, in <module>\nValueError: boom\n",
),
];
for (index, (source, expected_exit, expected_stderr)) in cases.into_iter().enumerate() {
let process_id = format!("proc-py-exit-{index}");
execute_python_cli(
&mut sidecar,
4 + index as RequestId,
&connection_id,
&session_id,
&vm_id,
&process_id,
"python3",
&["-c", source],
);
let (stdout, stderr, exit_code) = collect_process_output(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
&process_id,
);
assert_eq!(exit_code, expected_exit, "{source}\nstderr: {stderr}");
assert_eq!(stderr, expected_stderr, "{source}");
assert!(stdout.is_empty(), "{source}\nstdout: {stdout}");
}
}

fn python_command_runs_script_with_argv() {
assert_node_available();
let mut sidecar = new_sidecar("python-cli-argv");
Expand Down Expand Up @@ -4104,6 +4154,7 @@ fn python_rootfs_suite() {

fn python_cli_suite() {
python_command_runs_inline_code();
python_command_exits_with_cpython_status_and_traceback();
python_command_runs_script_with_argv();
python_command_runs_module_with_dash_m();
python_command_reads_program_from_stdin();
Expand Down Expand Up @@ -4212,6 +4263,7 @@ mod python_split {
python_runtime_runs_node_subprocesses_through_sidecar_bridge,
python_runtime_surfaces_subprocess_permission_errors,
python_command_runs_inline_code,
python_command_exits_with_cpython_status_and_traceback,
python_command_runs_script_with_argv,
python_command_runs_module_with_dash_m,
python_command_reads_program_from_stdin,
Expand Down
5 changes: 5 additions & 0 deletions docs/content/docs/python.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ Executions are ephemeral, so capture stdio only when you want it: `"stderr"` for
diagnostics, `"all"` for both streams. `onStdout`/`onStderr` stream live and work
independently of capture.

The exit code follows CPython. `sys.exit()` and `SystemExit` set it: no
argument exits `0`, an integer exits with that code, and any other value is
printed to stderr and exits `1`. An uncaught exception prints a Python traceback
to stderr and exits `1`.

## Pass data into code

In the library, `inputs` hands host values to the guest as real objects, so data never gets
Expand Down
Loading