demo_agent / agent /deployer.py
Emmanuel Acheampong
changes to the model names
50215f5
"""
Stage 4 — Deployer + Self-Healer
Deploys generated code to Hugging Face Spaces.
If the build fails, Qwen3 reads the error logs and patches the code.
Up to MAX_RETRIES self-heal cycles before giving up.
"""
import re
import time
import requests
from openai import OpenAI
from huggingface_hub import HfApi
from huggingface_hub.utils import HfHubHTTPError
from config import (
CRUSOE_API_KEY,
CRUSOE_BASE_URL,
HEALER_MODEL,
HF_TOKEN,
HF_USERNAME,
)
MAX_RETRIES = 3
POLL_INTERVAL = 15 # seconds between status checks
BUILD_TIMEOUT = 300 # seconds before we give up waiting
healer_client = OpenAI(api_key=CRUSOE_API_KEY, base_url=CRUSOE_BASE_URL)
HEALER_SYSTEM_PROMPT = """You are an expert Python/Streamlit debugging assistant.
You will receive broken Streamlit app code and a build/runtime error message.
Fix the code so it runs cleanly on Hugging Face Spaces with:
- Python 3.11
- streamlit and openai installed
- CRUSOE_API_KEY and CRUSOE_BASE_URL set as environment secrets
Output ONLY the corrected Python code. No markdown fences, no explanations."""
APP_REQUIREMENTS = "streamlit\nopenai\n"
# HF Spaces Docker spaces must run as uid 1000 (non-root)
DOCKERFILE = """\
FROM python:3.11-slim
RUN useradd -m -u 1000 user
USER user
ENV HOME=/home/user \\
PATH=/home/user/.local/bin:$PATH
WORKDIR $HOME/app
COPY --chown=user requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=user app.py .
EXPOSE 7860
CMD ["streamlit", "run", "app.py", \
"--server.port=7860", \
"--server.address=0.0.0.0", \
"--server.headless=true", \
"--server.enableCORS=false", \
"--server.enableXsrfProtection=false"]
"""
README_TEMPLATE = """---
title: "{title}"
emoji: 🚀
colorFrom: blue
colorTo: purple
sdk: docker
pinned: false
---
{description}
*Powered by [Crusoe Managed Inference](https://crusoe.ai)*
"""
def deploy(code: str, intent: dict, on_progress=None) -> str:
"""
Deploy code to HF Spaces and return the live URL.
Calls on_progress(message) at each significant step.
"""
api = HfApi(token=HF_TOKEN)
space_name = _slugify(intent.get("title", "crusoe-demo"))
repo_id = f"{HF_USERNAME}/{space_name}"
_log(on_progress, f"Creating Space `{repo_id}`...")
_create_space(api, repo_id)
safe_title = intent.get("title", "AI Demo").replace('"', "'")
readme = README_TEMPLATE.format(
title=safe_title,
description=intent.get("description", ""),
)
for attempt in range(1, MAX_RETRIES + 1):
_log(on_progress, f"Uploading files (attempt {attempt}/{MAX_RETRIES})...")
_upload_files(api, repo_id, code, readme)
_log(on_progress, "Waiting for Hugging Face build...")
status, error_log = _wait_for_build(api, repo_id, on_progress)
if status == "RUNNING":
app_url = _space_url(repo_id)
_log(on_progress, f"Space is live at {app_url}")
return app_url
if attempt < MAX_RETRIES:
_log(on_progress, f"Build failed. Asking {HEALER_MODEL} to fix the code...")
code = _heal(code, error_log)
else:
raise RuntimeError(
f"Deployment failed after {MAX_RETRIES} attempts.\nLast error:\n{error_log}"
)
raise RuntimeError("Unexpected exit from deploy loop")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _create_space(api: HfApi, repo_id: str) -> None:
try:
api.create_repo(
repo_id=repo_id,
repo_type="space",
space_sdk="docker",
exist_ok=True,
private=False,
)
# Inject Crusoe credentials as Space secrets
from config import CRUSOE_API_KEY as KEY, CRUSOE_BASE_URL as BASE_URL
api.add_space_secret(repo_id=repo_id, key="CRUSOE_API_KEY", value=KEY)
api.add_space_secret(repo_id=repo_id, key="CRUSOE_BASE_URL", value=BASE_URL)
except HfHubHTTPError as exc:
raise RuntimeError(f"Failed to create HF Space: {exc}") from exc
def _upload_files(api: HfApi, repo_id: str, code: str, readme: str) -> None:
files = {
"app.py": code.encode(),
"requirements.txt": APP_REQUIREMENTS.encode(),
"Dockerfile": DOCKERFILE.encode(),
"README.md": readme.encode(),
}
for path_in_repo, content in files.items():
api.upload_file(
path_or_fileobj=content,
path_in_repo=path_in_repo,
repo_id=repo_id,
repo_type="space",
)
def _wait_for_build(
api: HfApi, repo_id: str, on_progress=None
) -> tuple[str, str]:
"""
Poll the Space runtime until it reaches a terminal state.
Returns (stage_string, error_log).
"""
deadline = time.time() + BUILD_TIMEOUT
terminal_stages = {"RUNNING", "RUNTIME_ERROR", "BUILD_ERROR", "APP_CRASHED"}
while time.time() < deadline:
try:
runtime = api.get_space_runtime(repo_id=repo_id)
stage = str(runtime.stage)
_log(on_progress, f"Build status: {stage}")
if stage in terminal_stages:
if stage == "RUNNING":
return "RUNNING", ""
error_log = _fetch_build_logs(repo_id)
return stage, error_log
except Exception:
pass # transient API error — keep polling
time.sleep(POLL_INTERVAL)
return "TIMEOUT", "Build timed out after 5 minutes."
def _fetch_build_logs(repo_id: str) -> str:
"""Fetch build logs from the HF REST API."""
try:
owner, name = repo_id.split("/", 1)
url = f"https://huggingface.co/api/spaces/{owner}/{name}/logs"
headers = {"Authorization": f"Bearer {HF_TOKEN}"}
resp = requests.get(url, headers=headers, timeout=15)
if resp.ok:
logs = resp.json()
# logs is a list of {"type": "...", "data": "..."}
lines = [entry.get("data", "") for entry in logs if entry.get("data")]
return "\n".join(lines[-100:]) # last 100 lines
except Exception:
pass
return "Unable to retrieve build logs."
def _heal(code: str, error_log: str) -> str:
"""Ask HEALER_MODEL to fix the code given the error log."""
response = healer_client.chat.completions.create(
model=HEALER_MODEL,
messages=[
{"role": "system", "content": HEALER_SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"Error log:\n```\n{error_log}\n```\n\n"
f"Code to fix:\n```python\n{code}\n```"
),
},
],
temperature=0.1,
max_tokens=4096,
)
raw = response.choices[0].message.content or ""
fenced = re.search(r"```(?:python)?\n(.*?)```", raw, re.DOTALL)
return fenced.group(1).strip() if fenced else raw.strip()
def _space_url(repo_id: str) -> str:
owner, name = repo_id.split("/", 1)
slug = f"{owner}-{name}".replace(".", "-")
return f"https://{slug}.hf.space"
def _slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = text.strip("-")[:40]
return f"crusoe-{text}" if text else "crusoe-demo"
def _log(callback, message: str) -> None:
if callback:
callback(message)