Python API¶
This document describes the first Python interface for agent-supervisor.
The source binding is implemented in python/, but it has not been published
to PyPI. It wraps the native backend and preserves the Rust library's process
cleanup and policy semantics.
Goals¶
- Give Python applications a safe argv-based way to run native tool processes.
- Keep output bounded and apply a deadline to every invocation.
- Return a structured result that says how execution ended and what enforcement the backend actually provided.
- Make asynchronous Python applications the primary integration path.
- Reject policy requests the active platform cannot enforce.
The library remains a process supervisor. Authentication, agent identity, authorization decisions, tool schemas, and provider credentials stay in the calling application.
Package and module¶
The distribution will be named agent-supervisor and imported as
agent_supervisor. Build the native extension with PyO3 and maturin from the
repository checkout. The package has not been published to PyPI yet; platform
wheels and a source distribution can be published after native CI covers the
supported operating systems. Prefer PyPI Trusted Publishing from GitHub Actions
over a long-lived upload token.
The Python package is versioned with the native crate. PyPI publication is a separate release step and has not happened yet.
First release: one-shot async execution¶
from agent_supervisor import Command, Policy, run
result = await run(
Command(
argv=["/usr/bin/rg", "TODO", "src"],
cwd="/workspace/project",
timeout=10.0,
max_stdout_bytes=256 * 1024,
max_stderr_bytes=32 * 1024,
env={"LANG": "C.UTF-8"},
),
policy=Policy(network="host"),
input=b"",
)
print(result.stdout.decode("utf-8", errors="replace"))
print(result.termination, result.exit_code, result.enforcement)
Command.argv is a non-empty sequence of strings. The first item must be an
absolute executable path; arguments are passed directly to the operating
system, and the library never inserts a shell. cwd and executable paths are
resolved and validated according to the native API. env means the explicit
environment additions/replacements; ambient
inheritance remains denied by default. An explicit inherit_env allowlist can
be offered for callers that need selected host variables.
Implemented Command options:
| Option | Meaning |
|---|---|
argv |
Executable and arguments, with no implicit shell |
cwd |
Optional working directory |
env |
Explicit environment variables to release to the child |
inherit_env |
Optional names to copy from the host environment |
timeout |
Positive deadline in seconds |
max_stdin_bytes |
Maximum input accepted by run(input=...) |
max_stdout_bytes |
Bound for collected stdout |
max_stderr_bytes |
Bound for collected stderr |
Policy maps to the current Rust policy: network, optional filesystem grants,
and enforcement (best_effort or required). Resource limits are exposed
only where the corresponding backend capability exists.
Result and error contract¶
run() returns a RunResult when the child ran to completion, hit its
deadline, was cancelled through an explicit CancellationToken, or exceeded
an output limit. It raises a structured
SupervisorError for invalid requests, unavailable required enforcement,
spawn/I/O failures, and other operational errors.
RunResult should expose:
stdoutandstderras boundedbytes;successas a convenience derived from a zero exit;exit_codeandsignal, when available;terminationas one ofexited,timed_out,cancelled,stdout_limit_exceeded, orstderr_limit_exceeded;enforcement(enforced,degraded, ortrusted);- requested and canonical executable identity; and
- monotonic duration plus wall-clock start/finish timestamps.
Do not make stdout implicitly decode to text. A helper such as
result.stdout_text(encoding="utf-8", errors="replace") and the matching
stderr_text() helper are provided.
Streaming output¶
stream() yields bounded byte chunks as they arrive. Set capture=True to
also collect those chunks, within the same stdout and stderr limits, and
include them in the terminal event's result. Streaming without capture keeps
the binding from accumulating a second copy of the output.
from agent_supervisor import Command, Policy, stream
async with stream(
Command(
argv=["/usr/bin/tool", "--verbose"],
timeout=30.0,
max_stdout_bytes=512 * 1024,
max_stderr_bytes=64 * 1024,
),
policy=Policy(network="host"),
capture=True,
) as events:
async for event in events:
if event.kind == "stdout":
print(event.data.decode("utf-8", errors="replace"), end="")
elif event.kind == "stderr":
report(event.data)
elif event.kind == "exited":
result = event.result
stream() is for output streaming from a command that does not need stdin; it
closes the child's stdin immediately. Use run(..., input=...) for bounded
one-shot input. Interactive stdin and persistent process handles remain a
later phase. If a consumer pauses, output queues apply backpressure while the
deadline and explicit cancellation remain active. Dropping the stream closes
its receiver and triggers child cleanup. An output-limit breach raises
SupervisorError after cleanup.
Use the async context manager even when consuming only part of the stream.
Leaving the context calls aclose() and waits for process cleanup. Cancelling
the task also exits the context and performs that cleanup before cancellation
propagates.
SupervisorError retains stable machine-readable fields matching the
Rust error: code, phase, message, retryable, optional executable,
optional termination, and optional bounded stderr_tail. Python exception
callers should branch on
code, not parse the message.
Async cancellation¶
Expose a small CancellationToken with cancel() and cancelled(). Passing a
token to run(..., cancellation=token) and calling token.cancel() returns a
normal RunResult with termination="cancelled" after cleanup and reaping.
Cancelling the Python task itself must instead terminate the owned process
group, wait for cleanup/reaping, then propagate asyncio.CancelledError. The
binding must not simply drop the Rust future or stop reading output. If callers
need a termination receipt they can inspect, use the explicit cancellation
token or the process-handle API's terminate() method.
Later phase: persistent process handle and interactive streaming¶
Persistent processes should have an explicit async context manager so ordinary scope exit cleans up the child:
async with await supervisor.spawn(
Command(argv=["/usr/bin/my-tool", "--interactive"])
) as process:
await process.write_stdin(b"request\n")
await process.close_stdin()
async for event in process.events():
if event.kind == "stdout":
consume(event.data)
elif event.kind == "stderr":
report(event.data)
elif event.kind == "exited":
receipt = event.receipt
result = await process.wait()
The handle should also offer terminate(grace=...) -> ProcessReceipt,
wait() -> RunResult, and an idempotent close(). Output event payloads are
bytes and share the configured output bounds. Ending an event iterator does not
mean the child was terminated; termination must be explicit or happen through
the context manager's cleanup.
Scope boundaries for v1¶
- Start with one-shot execution and output streaming; add persistent handles after cancellation, stream backpressure, and cleanup behavior are verified on Linux, macOS, and Windows.
- Do not initially expose supervision restart policies, process adoption, pipelines, or PTYs. Add these after their ownership and error models have idiomatic Python designs.
- Do not expose OpenShell as if it were a production Python backend. Its current adapter is developer-only and command execution in an existing managed sandbox has distinct cancellation and policy limits.
- Do not silently downgrade required filesystem, network, or resource enforcement. Return a structured error when the selected platform cannot satisfy it.
Implementation outline¶
- Add a
python/package built with maturin and an ABI strategy chosen for the supported Python versions. - Bind the stable request/result/error types and async
run()first. - Add Python unit tests for request conversion, error conversion, byte output, and cancellation cleanup, plus native subprocess integration tests per OS.
- Build and inspect wheels for Linux, macOS, and Windows in CI; test a source install as well.
- Publish to PyPI through a configured Trusted Publisher only after the API and platform matrix are green.