Skip to content

BackgroundShells

Python · package toolnexus · SPEC §4A · python/src/toolnexus/builtin_extras.py

class BackgroundShells:
def list(self) -> list[BackgroundShellInfo] # oldest first
def kill(self, job_id: str) -> None # KeyError on an unknown id
def close_all(self) -> None # stop everything still running
create_toolkit(builtins={"background": BackgroundShells(), "kill_grace_ms": 2000})
# bash {command, run_in_background: true} -> metadata["backgroundId"]
# bash_output {id} output since the last read + "[status: running|killed|exited with code N]"
# kill_shell {id} SIGTERM the process group, wait kill_grace_ms, then SIGKILL

Passing a BackgroundShells as builtins["background"] turns on three things: bash gains a run_in_background flag, and two tools appear, bash_output and kill_shell. Without it none of this exists and the default builtins are unchanged. A background command is not tied to the turn that started it, so the host owns the registry and must call close_all() on shutdown. A shell killed by a signal it did not get from kill_shell reports exited with code -1.

  • A coding agent that must start a dev server or a watch build and keep working while it runs.
  • Long test suites whose output the model polls instead of blocking a turn.

1. The smallest useful call — start, poll, read

Section titled “1. The smallest useful call — start, poll, read”
import asyncio
from toolnexus import BackgroundShells, create_toolkit
async def main():
bg = BackgroundShells()
tk = await create_toolkit(builtins={"background": bg})
try:
r = await tk.execute("bash", {"command": "echo built", "run_in_background": True})
bid = r.metadata["backgroundId"]
while True:
out = await tk.execute("bash_output", {"id": bid})
if out.metadata["status"] != "running":
break
await asyncio.sleep(0.05)
assert out.output.startswith("built"), out.output
assert out.output.endswith("[status: exited with code 0]"), out.output
print("ok:", bid, out.metadata["status"])
finally:
bg.close_all()
asyncio.run(main())

2. The realistic case — stop a long-running server with kill_shell

Section titled “2. The realistic case — stop a long-running server with kill_shell”
import asyncio
from toolnexus import BackgroundShells, create_toolkit
async def main():
bg = BackgroundShells()
tk = await create_toolkit(builtins={"background": bg, "kill_grace_ms": 100})
try:
r = await tk.execute("bash", {"command": "sleep 30", "run_in_background": True})
bid = r.metadata["backgroundId"]
k = await tk.execute("kill_shell", {"id": bid})
assert not k.is_error and k.metadata["killed"] is True
out = await tk.execute("bash_output", {"id": bid})
assert out.metadata["status"] == "killed"
print("ok: killed", bid)
finally:
bg.close_all()
asyncio.run(main())

3. The full surface — the host’s view, and shutdown

Section titled “3. The full surface — the host’s view, and shutdown”
import asyncio
from toolnexus import BackgroundShells, create_toolkit
async def main():
bg = BackgroundShells()
tk = await create_toolkit(builtins={"background": bg, "kill_grace_ms": 100})
assert "run_in_background" in tk.get("bash").input_schema["properties"]
for cmd in ("sleep 30", "sleep 30"):
await tk.execute("bash", {"command": cmd, "run_in_background": True})
running = [i for i in bg.list() if i.running]
assert len(running) == 2
bg.close_all() # on host shutdown: stops every process group
assert not any(i.running for i in bg.list())
plain = await create_toolkit()
assert plain.get("bash_output") is None # off unless configured
print("ok: closed", len(running), "background shells")
asyncio.run(main())
  • select_builtins — Pick which built-in file/shell/web tools are in play, by name or by source toggle.
  • create_builtin_tools — Construct every built-in tool directly, outside a toolkit.