Skip to content
Closed
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
22 changes: 3 additions & 19 deletions data/config.yaml
Original file line number Diff line number Diff line change
@@ -1,36 +1,20 @@
server:
host: 0.0.0.0
host: 127.0.0.1
port: 8888

backends:
- name: local-npu
type: rkllama
url: http://localhost:8080
priority: 3
- name: fedora-gpu
type: ollama
url: http://100.99.131.81:11434
priority: 1

qmd:
url: http://100.78.225.80:7832
url: http://localhost:7832

# Agent data (memory, QMD database) always lives inside the agent's LXC.
# TinyAgentOS accesses it via the agent's qmd_url (each agent runs qmd serve).
# This ensures multi-host fallback works — data follows the agent, not the host.
agents:
- name: naira
host: 192.168.6.214
qmd_url: http://192.168.6.214:7832
color: "#98fb98"
- name: stanley
host: 192.168.6.212
qmd_url: http://192.168.6.212:7832
color: "#ffd700"
- name: mary
host: 192.168.6.213
qmd_url: http://192.168.6.213:7832
color: "#ff7eb3"
agents: []

metrics:
poll_interval: 30
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/venv/bin/python -m uvicorn tinyagentos.app:create_app --factory --host 0.0.0.0 --port 8888
ExecStart=$INSTALL_DIR/venv/bin/python -m uvicorn tinyagentos.app:create_app --factory --host 127.0.0.1 --port 8888
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Expand Down
17 changes: 15 additions & 2 deletions tinyagentos/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
VALID_BACKEND_TYPES = {"rkllama", "ollama", "llama-cpp", "vllm"}

DEFAULT_CONFIG = {
"server": {"host": "0.0.0.0", "port": 8888},
"server": {"host": "127.0.0.1", "port": 8888},
"backends": [],
"qmd": {"url": "http://localhost:7832"},
"agents": [],
Expand Down Expand Up @@ -53,9 +53,22 @@ def load_config(path: Path) -> AppConfig:
config_path=path,
)

import re

AGENT_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")

def validate_agent_name(name: str) -> str | None:
"""Validate agent name for safe use in container names and paths.
Returns error message or None if valid."""
if not AGENT_NAME_RE.match(name):
return "Agent name must be 1-63 lowercase alphanumeric chars or hyphens, starting with alphanumeric"
return None

def save_config(config: AppConfig, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
tmp_path = path.with_suffix(".yaml.tmp")
tmp_path.write_text(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
tmp_path.replace(path)

async def save_config_locked(config: AppConfig, path: Path) -> None:
async with _config_lock:
Expand Down
143 changes: 77 additions & 66 deletions tinyagentos/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@

[Service]
Type=simple
ExecStart=/usr/local/bin/qmd serve --port 7832 --bind 0.0.0.0 --backend rkllama --rkllama-url {rkllama_url}
ExecStart=/usr/local/bin/qmd serve --port 7832 --bind {bind} --backend rkllama --rkllama-url {rkllama_url}
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
Expand All @@ -42,7 +43,11 @@ class DeployRequest:


async def deploy_agent(req: DeployRequest) -> dict:
"""Full agent deployment: create container → install deps → configure → start."""
"""Full agent deployment: create container → install deps → configure → start.
Rolls back (destroys container) on any critical failure after creation."""
import asyncio
import tempfile

container_name = f"agent-{req.name}"
steps = []

Expand All @@ -58,76 +63,82 @@ async def deploy_agent(req: DeployRequest) -> dict:
return {"success": False, "error": f"Container creation failed: {result.get('error')}", "steps": steps}
steps.append("container_created")

# Step 2: Wait for network
import asyncio
for _ in range(10):
code, output = await exec_in_container(container_name, ["hostname", "-I"])
if code == 0 and output.strip():
break
await asyncio.sleep(2)
steps.append("network_ready")

# Step 3: Install base dependencies
logger.info(f"Installing dependencies in {container_name}")
code, output = await exec_in_container(
container_name,
["bash", "-c", "apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv nodejs npm git curl"],
timeout=600,
)
if code != 0:
return {"success": False, "error": f"Dependency install failed: {output}", "steps": steps}
steps.append("deps_installed")

# Step 4: Install QMD
logger.info(f"Installing QMD in {container_name}")
code, output = await exec_in_container(
container_name,
["npm", "install", "-g", "github:jaylfc/qmd#feat/remote-llm-provider"],
timeout=300,
)
if code != 0:
logger.warning(f"QMD install warning: {output}")
steps.append("qmd_installed")
try:
# Step 2: Wait for network
for _ in range(10):
code, output = await exec_in_container(container_name, ["hostname", "-I"])
if code == 0 and output.strip():
break
await asyncio.sleep(2)
steps.append("network_ready")

# Step 3: Install base dependencies
logger.info(f"Installing dependencies in {container_name}")
code, output = await exec_in_container(
container_name,
["bash", "-c", "apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv nodejs npm git curl"],
timeout=600,
)
if code != 0:
raise RuntimeError(f"Dependency install failed: {output}")
steps.append("deps_installed")

# Step 5: Configure qmd serve systemd service
qmd_service_content = QMD_SERVICE.format(rkllama_url=req.rkllama_url)
# Write to temp file and push
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".service", delete=False) as f:
f.write(qmd_service_content)
tmp_path = f.name
await push_file(container_name, tmp_path, "/etc/systemd/system/qmd-serve.service")
Path(tmp_path).unlink()
await exec_in_container(container_name, ["systemctl", "daemon-reload"])
await exec_in_container(container_name, ["systemctl", "enable", "qmd-serve"])
await exec_in_container(container_name, ["systemctl", "start", "qmd-serve"])
steps.append("qmd_serve_configured")

# Step 6: Install agent framework (if specified and not just "none")
if req.framework and req.framework != "none":
logger.info(f"Installing framework {req.framework} in {container_name}")
# Step 4: Install QMD
logger.info(f"Installing QMD in {container_name}")
code, output = await exec_in_container(
container_name,
["pip3", "install", req.framework],
["npm", "install", "-g", "github:jaylfc/qmd#feat/remote-llm-provider"],
timeout=300,
)
if code != 0:
logger.warning(f"Framework install warning: {output}")
steps.append("framework_installed")

# Step 7: Get container IP
code, output = await exec_in_container(container_name, ["hostname", "-I"])
container_ip = output.strip().split()[0] if code == 0 and output.strip() else None
steps.append("deployment_complete")

return {
"success": True,
"name": req.name,
"container": container_name,
"ip": container_ip,
"qmd_url": f"http://{container_ip}:7832" if container_ip else None,
"steps": steps,
}
logger.warning(f"QMD install warning: {output}")
steps.append("qmd_installed")

# Step 5: Configure qmd serve systemd service
# Bind to 0.0.0.0 inside container so the host can reach it
qmd_service_content = QMD_SERVICE.format(bind="0.0.0.0", rkllama_url=req.rkllama_url)
with tempfile.NamedTemporaryFile(mode="w", suffix=".service", delete=False) as f:
f.write(qmd_service_content)
tmp_path = f.name
await push_file(container_name, tmp_path, "/etc/systemd/system/qmd-serve.service")
Path(tmp_path).unlink()
await exec_in_container(container_name, ["systemctl", "daemon-reload"])
await exec_in_container(container_name, ["systemctl", "enable", "qmd-serve"])
await exec_in_container(container_name, ["systemctl", "start", "qmd-serve"])
steps.append("qmd_serve_configured")

# Step 6: Install agent framework (if specified and not just "none")
if req.framework and req.framework != "none":
logger.info(f"Installing framework {req.framework} in {container_name}")
code, output = await exec_in_container(
container_name,
["pip3", "install", req.framework],
timeout=300,
)
if code != 0:
logger.warning(f"Framework install warning: {output}")
steps.append("framework_installed")

# Step 7: Get container IP
code, output = await exec_in_container(container_name, ["hostname", "-I"])
container_ip = output.strip().split()[0] if code == 0 and output.strip() else None
steps.append("deployment_complete")

return {
"success": True,
"name": req.name,
"container": container_name,
"ip": container_ip,
"qmd_url": f"http://{container_ip}:7832" if container_ip else None,
"steps": steps,
}

except Exception as exc:
logger.error(f"Deploy failed at step {steps[-1] if steps else 'init'}: {exc}")
logger.info(f"Rolling back: destroying container {container_name}")
await destroy_container(container_name)
steps.append("rolled_back")
return {"success": False, "error": str(exc), "steps": steps}


async def undeploy_agent(name: str) -> dict:
Expand Down
12 changes: 11 additions & 1 deletion tinyagentos/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import BaseModel

from tinyagentos.agent_db import find_agent, get_agent_summaries
from tinyagentos.config import save_config_locked
from tinyagentos.config import save_config_locked, validate_agent_name

router = APIRouter()

Expand Down Expand Up @@ -49,6 +49,9 @@ async def get_agent_endpoint(request: Request, name: str):
@router.post("/api/agents")
async def add_agent(request: Request, body: AgentCreate):
config = request.app.state.config
name_error = validate_agent_name(body.name)
if name_error:
return JSONResponse({"error": name_error}, status_code=400)
if find_agent(config, body.name):
return JSONResponse({"error": f"Agent '{body.name}' already exists"}, status_code=409)
config.agents.append(body.model_dump())
Expand Down Expand Up @@ -89,9 +92,16 @@ class DeployAgentRequest(BaseModel):
cpu_limit: int = 2


ALLOWED_FRAMEWORKS = {"none", "smolagents", "pocketflow", "openclaw", "nanoclaw", "picoclaw", "swarm", "langroid", "openai-agents-sdk"}

@router.post("/api/agents/deploy")
async def deploy_agent_endpoint(request: Request, body: DeployAgentRequest):
config = request.app.state.config
name_error = validate_agent_name(body.name)
if name_error:
return JSONResponse({"error": name_error}, status_code=400)
if body.framework not in ALLOWED_FRAMEWORKS:
return JSONResponse({"error": f"Unknown framework '{body.framework}'. Allowed: {sorted(ALLOWED_FRAMEWORKS)}"}, status_code=400)
if find_agent(config, body.name):
return JSONResponse({"error": f"Agent '{body.name}' already exists"}, status_code=409)

Expand Down