foxy-dev-team/backend/app/openclaw.py

202 lines
6.9 KiB
Python

"""
OpenClaw CLI integration — async wrapper for spawning agents.
Replaces the synchronous subprocess-based spawn_agent() from foxy-autopilot.py.
"""
import asyncio
import logging
import platform
from typing import Optional
from app.config import settings
log = logging.getLogger("foxy.openclaw")
# ─── Agent Labels ──────────────────────────────────────────────────────────────
AGENT_LABELS = {
"Foxy-Conductor": "foxy-conductor",
"Foxy-Architect": "foxy-architect",
"Foxy-Dev": "foxy-dev",
"Foxy-UIUX": "foxy-uiux",
"Foxy-QA": "foxy-qa",
"Foxy-Admin": "foxy-admin",
}
# ─── Spawn Command Detection ──────────────────────────────────────────────────
_detected_command: Optional[list[str]] = None
async def _run_help(args: list[str], timeout: int = 8) -> str:
"""Run a help command and return its output."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (stdout.decode() + stderr.decode()).lower()
except (asyncio.TimeoutError, FileNotFoundError):
return ""
except Exception:
return ""
async def detect_openclaw_syntax() -> Optional[list[str]]:
"""
Auto-detect the correct openclaw CLI syntax for spawning agents.
Returns a template list like ["openclaw", "agent", "--agent", "{agent}", "--task", "{task}"]
"""
global _detected_command
candidates = [
(
["openclaw", "agent", "--help"],
["openclaw", "agent", "--agent", "{agent}", "--task", "{task}"],
["agent", "task"],
),
(
["openclaw", "agent", "--help"],
["openclaw", "agent", "--agent", "{agent}", "--message", "{task}"],
["agent", "message"],
),
(
["openclaw", "agents", "run", "--help"],
["openclaw", "agents", "run", "--agent", "{agent}", "--task", "{task}"],
["agent", "task"],
),
(
["openclaw", "agents", "spawn", "--help"],
["openclaw", "agents", "spawn", "--agent", "{agent}", "--task", "{task}"],
["agent", "task"],
),
]
for help_cmd, spawn_template, keywords in candidates:
output = await _run_help(help_cmd)
if not output:
continue
if all(kw in output for kw in keywords):
log.info(f"OpenClaw syntax detected: {' '.join(spawn_template[:5])}")
_detected_command = spawn_template
return spawn_template
log.warning("No known openclaw syntax detected")
return None
def build_spawn_cmd(template: list[str], agent_label: str, task_msg: str) -> list[str]:
"""Build the actual command by replacing placeholders."""
return [
t.replace("{agent}", agent_label).replace("{task}", task_msg)
for t in template
]
# ─── Agent Spawning ───────────────────────────────────────────────────────────
async def spawn_agent(
agent_name: str,
task_message: str,
) -> Optional[asyncio.subprocess.Process]:
"""
Spawn an OpenClaw agent asynchronously.
Returns the Process object if successful, None on failure.
"""
global _detected_command
if _detected_command is None:
_detected_command = await detect_openclaw_syntax()
if _detected_command is None:
log.error("Cannot spawn agent — openclaw syntax not detected")
return None
agent_label = AGENT_LABELS.get(agent_name, agent_name.lower().replace(" ", "-"))
cmd = build_spawn_cmd(_detected_command, agent_label, task_message)
log.info(f"Spawning {agent_name} (label: {agent_label})")
cmd_display = " ".join(c if len(c) < 50 else c[:47] + "..." for c in cmd)
log.info(f"CMD: {cmd_display}")
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=settings.OPENCLAW_WORKSPACE,
)
# Wait briefly to check for immediate failure
await asyncio.sleep(3)
if proc.returncode is not None and proc.returncode != 0:
stderr = await proc.stderr.read()
err_msg = stderr.decode()[:400]
log.error(f"Agent spawn failed immediately (code {proc.returncode}): {err_msg}")
return None
log.info(f"Agent {agent_name} spawned (PID: {proc.pid})")
return proc
except FileNotFoundError:
log.error("'openclaw' not found in PATH")
return None
except Exception as e:
log.error(f"Error spawning {agent_name}: {e}")
return None
def build_task_for_agent(
agent_name: str,
project_name: str,
project_slug: str,
description: str,
test_mode: bool = False,
) -> str:
"""Build the task/message string sent to an agent via OpenClaw."""
base = (
f"Tu es {agent_name}. "
f"Projet actif : {project_name} (slug: {project_slug}). "
f"{'MODE TEST : simule ton travail sans produire de code réel. ' if test_mode else ''}"
f"Connecte-toi à l'API Foxy Dev Team pour lire l'état du projet et mettre à jour tes résultats. "
)
instructions = {
"Foxy-Conductor": (
base
+ "MISSION : Analyse la description du projet. "
+ "Crée les tâches initiales, puis change le statut à l'étape suivante du workflow. "
),
"Foxy-Architect": (
base
+ "MISSION : Produis l'architecture technique (ADR), "
+ "découpe en tickets avec assigned_to, acceptance_criteria, depends_on. "
),
"Foxy-Dev": (
base
+ "MISSION : Prends les tâches PENDING assignées à toi. "
+ "Écris le code, commit sur branche task/TASK-XXX via Gitea. "
),
"Foxy-UIUX": (
base
+ "MISSION : Prends les tâches UI/PENDING assignées à toi. "
+ "Crée les composants React/TypeScript, commit sur branche task/TASK-XXX-ui. "
),
"Foxy-QA": (
base
+ "MISSION : Audite toutes les tâches IN_REVIEW. "
+ "Approuve ou rejette avec feedback détaillé. "
),
"Foxy-Admin": (
base
+ "MISSION : Déploie toutes les tâches READY_FOR_DEPLOY. "
+ "Backup avant déploiement. Génère le rapport final si tout est DONE. "
),
}
return instructions.get(agent_name, base + "Exécute ta mission.")