AgentToolStore / client /src /toolstore /docker_pool.py
ToolStore Agent
refactor: remove api/docker types, add toolset type
2036bab
Raw
History Blame Contribute Delete
12.4 kB
"""
Docker container management for ToolStore.
Two separate container models:
1. **Single shared worker** — one persistent Python worker for ALL toolset
executions. The container starts once and stays alive forever. Toolset
code is piped in/out per invocation via newline-delimited JSON.
2. **MCP-server containers** — one container per MCP server (started by
``DockerTransport`` in ``transport.py``). JSON-RPC over stdin/stdout.
"""
from __future__ import annotations
import json
import subprocess
import threading
import time
from typing import Dict, Any, Optional, List
# ---------------------------------------------------------------------------
# Worker bootstrap (injected into every warm Python container)
# ---------------------------------------------------------------------------
WORKER_BOOTSTRAP = r'''
import sys as _sys, json as _json, traceback as _tb
from io import StringIO as _StringIO
_modules = {} # module_name -> namespace holding its functions
_sys.__stdout__.write("READY\n")
_sys.__stdout__.flush()
while True:
_line = _sys.stdin.readline()
if not _line:
break
try:
_req = _json.loads(_line)
except _json.JSONDecodeError:
continue
_rid = _req["id"]
_action = _req.get("action", "call")
if _action == "load":
# Load a module — exec the code, store its namespace.
_name = _req["module"]
_code = _req["code"]
_ns = {}
try:
exec(_code, _ns)
except Exception:
_res = {"id": _rid, "ok": False, "output": _tb.format_exc()}
else:
_modules[_name] = _ns
_res = {"id": _rid, "ok": True, "output": f"module '{_name}' loaded"}
elif _action == "call":
_mod = _req["module"]
_func = _req["function"]
_args = _req.get("args", {})
_ns = _modules.get(_mod)
if _ns is None:
_res = {"id": _rid, "ok": False,
"output": f"module '{_mod}' not loaded"}
else:
_fn = _ns.get(_func)
if _fn is None:
_res = {"id": _rid, "ok": False,
"output": f"function '{_func}' not found in module '{_mod}'"}
elif not callable(_fn):
_res = {"id": _rid, "ok": False,
"output": f"'{_func}' in module '{_mod}' is not callable"}
else:
_cap = _StringIO()
_old = _sys.stdout
_sys.stdout = _cap
try:
_result = _fn(**_args)
_output = _cap.getvalue()
if _result is not None:
_output += _json.dumps(_result, default=str)
_res = {"id": _rid, "ok": True, "output": _output}
except Exception:
_res = {"id": _rid, "ok": False,
"output": _cap.getvalue() + _tb.format_exc()}
finally:
_sys.stdout = _old
else:
_res = {"id": _rid, "ok": False,
"output": f"unknown action '{_action}'"}
_sys.__stdout__.write(_json.dumps(_res) + "\n")
_sys.__stdout__.flush()
'''
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def check_docker_available() -> Optional[str]:
"""Return ``None`` if Docker is usable, otherwise an error message."""
import shutil
if shutil.which("docker") is None:
return "Docker is not installed or not on PATH."
try:
subprocess.run(
["docker", "info"],
capture_output=True, text=True, timeout=10,
)
except subprocess.TimeoutExpired:
return "Docker daemon is unresponsive (timeout)."
except FileNotFoundError:
return "Docker CLI not found."
except Exception as exc:
return f"Cannot communicate with Docker daemon: {exc}"
return None
def detect_dind() -> bool:
"""Return ``True`` if this process is running inside a Docker container."""
import os
if os.path.exists("/.dockerenv"):
return True
try:
with open("/proc/self/cgroup", "r") as fh:
if "docker" in fh.read() or "kubepods" in fh.read():
return True
except Exception:
pass
if os.environ.get("THINKTOOL_DOCKER") == "1":
return True
return False
def dind_socket_check() -> Optional[str]:
"""If we are inside Docker but ``/var/run/docker.sock`` is missing,
return a helpful error message; otherwise ``None``."""
import os
if detect_dind() and not os.path.exists("/var/run/docker.sock"):
return (
"Error: Running inside Docker but /var/run/docker.sock is not mounted.\n"
"Add this to docker-compose.yml:\n"
" volumes:\n"
" - /var/run/docker.sock:/var/run/docker.sock"
)
return None
# ---------------------------------------------------------------------------
# WarmContainer — the single shared Python worker for all toolset executions
# ---------------------------------------------------------------------------
class WarmContainer:
"""The single persistent Docker container that runs ALL toolset executions.
Started once (lazily, on first use) and kept alive forever. Toolset
modules are loaded once and cached in the worker; subsequent calls
invoke named functions from those modules.
"""
def __init__(self, image: str, container_name: str) -> None:
self.image = image
self.name = container_name
self.proc: Optional[subprocess.Popen] = None
self._request_id: int = 0
self._lock = threading.Lock()
self._alive = False
self._loaded_modules: set = set() # track what we've sent load for
# -- lifecycle -----------------------------------------------------------
def start(self) -> None:
"""Run *docker run -i* and bootstrap the worker."""
self.proc = subprocess.Popen(
[
"docker", "run", "-i", "--rm",
"--name", self.name,
"--network", "none",
"--cpus", "1",
"--memory", "256m",
self.image,
"python", "-u",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
# Bootstrap: load the worker loop as a module via the load action.
self._write_line(json.dumps({
"id": "_bootstrap",
"action": "load",
"module": "_worker",
"code": WORKER_BOOTSTRAP,
}))
# Discard the bootstrap response, then send a dummy call to
# confirm the worker is running and reading.
self._read_line(timeout=15)
self._write_line(json.dumps({
"id": "_ping",
"action": "call",
"module": "_worker",
"function": "_whoami_does_not_exist",
"args": {},
}))
raw = self._read_line(timeout=15)
if raw is None:
self.stop()
raise RuntimeError("Worker did not respond after bootstrap")
# Any response (even error) means the worker is alive.
self._alive = True
def stop(self) -> None:
self._alive = False
if self.proc:
try:
self.proc.stdin.close()
except Exception:
pass
try:
self.proc.terminate()
self.proc.wait(timeout=5)
except Exception:
try:
self.proc.kill()
except Exception:
pass
self.proc = None
def is_alive(self) -> bool:
return (
self._alive
and self.proc is not None
and self.proc.poll() is None
)
# -- load / call ---------------------------------------------------------
def load_module(self, name: str, code: str, timeout_s: int = 15) -> str:
"""Load *code* into the worker as a named module (idempotent).
Returns the worker response string (usually a confirmation or error).
"""
if name in self._loaded_modules:
return "" # already loaded
with self._lock:
rid = self._request_id
self._request_id += 1
self._write_line(json.dumps({
"id": rid, "action": "load", "module": name, "code": code,
}))
raw = self._read_line(timeout=timeout_s)
if raw is None:
return f"Error: load timed out for module '{name}'"
try:
result = json.loads(raw)
except json.JSONDecodeError:
return f"Error: invalid load response: {raw!r}"
if result.get("ok"):
self._loaded_modules.add(name)
return ""
return result.get("output", "unknown load error")
def call_function(
self, module: str, function: str,
args: Dict[str, Any], timeout_s: int,
) -> str:
"""Call *function* inside *module* with keyword *args*."""
with self._lock:
rid = self._request_id
self._request_id += 1
self._write_line(json.dumps({
"id": rid, "action": "call",
"module": module, "function": function,
"args": args,
}))
raw = self._read_line(timeout=timeout_s + 5)
if raw is None:
return f"Error: call timed out after {timeout_s}s"
try:
result = json.loads(raw)
except json.JSONDecodeError:
return f"Error: invalid call response: {raw!r}"
if result.get("ok"):
out = result.get("output", "")
return out.strip() if out.strip() else "(no output)"
return f"Error:\n{result.get('output', 'unknown error')}"
# -- I/O helpers ---------------------------------------------------------
def _write_line(self, text: str) -> None:
assert self.proc and self.proc.stdin
self.proc.stdin.write(text + "\n")
self.proc.stdin.flush()
def _read_line(self, timeout: float) -> Optional[str]:
"""Read a single line from the worker stdout with a timeout.
Uses a separate thread so we can enforce a wall-clock deadline
without relying on file-descriptor-level timeouts.
"""
assert self.proc and self.proc.stdout
result: List[Optional[str]] = [None]
def _target() -> None:
try:
result[0] = self.proc.stdout.readline() # type: ignore[union-attr]
except Exception:
result[0] = None
t = threading.Thread(target=_target, daemon=True)
t.start()
t.join(timeout)
if t.is_alive():
# Timed out — the thread will eventually read and discard.
return None
return result[0]
# ---------------------------------------------------------------------------
# Module-level singleton — the one shared worker
# ---------------------------------------------------------------------------
_worker: Optional[WarmContainer] = None
_worker_lock = threading.Lock()
def get_worker() -> WarmContainer:
"""Return (or lazily start) the single shared worker container.
The image comes from the user's config (``default_docker_image``).
"""
global _worker
if _worker is not None and _worker.is_alive():
return _worker
with _worker_lock:
# Double-check inside the lock
if _worker is not None and _worker.is_alive():
return _worker
# Import here to avoid circularity at module level
from toolstore.config_manager import ConfigManager
cfg = ConfigManager()
cfg.load()
image = cfg.get_default_docker_image()
name = f"ts_worker"
_worker = WarmContainer(image, name)
_worker.start()
return _worker
def shutdown_worker() -> None:
"""Stop the shared worker (e.g. at process exit)."""
global _worker
with _worker_lock:
if _worker:
_worker.stop()
_worker = None