{"repo": "andreisilva1/osymandias", "files": {"sdk/osymandias/cli/main.py": {"0fb440164c3fa0e86c802eddaedb50babb94fcca": {"t": 1785745429.9003797, "n": 38820, "h": "684a7c788d84b6f0", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\ndef _version_callback(value: bool) -> None:\n if value:\n import osymandias\n console.print(f\"osy {osymandias.__version__}\")\n raise typer.Exit()\n\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\n\n@app.callback()\ndef _main(\n version: bool = typer.Option(\n False, \"--version\", \"-V\",\n callback=_version_callback,\n is_eager=True,\n help=\"Show version and exit.\",\n ),\n) -> None:\n pass\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# Event colouring + formatting shared by `osy logs` and `osy submit --watch`.\n_EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"JOB_BUDGET_EXCEEDED\": \"bold red\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\", \"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"TASK_AWAITING_APPROVAL\": \"yellow\", \"TASK_APPROVED\": \"green\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n}\n\n\ndef _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init(\n example: bool = typer.Option(False, \"--example\", help=\"Also scaffold a runnable example agent + tool.\"),\n):\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n # Optional runnable example (agent + tool), auto-discovered by `osy serve`.\n if example:\n example_dest = cwd / \"example_agent.py\"\n if example_dest.exists():\n console.print(\"[yellow]↳ example_agent.py already exists, skipping[/yellow]\")\n else:\n _write_example_agent(example_dest)\n console.print(\"[green]✓[/green] example_agent.py created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\")\n if example:\n console.print('[dim]Then try:[/dim] [cyan]osy submit \"Summarise: OSymandias is a multi-agent runtime.\" --watch[/cyan]')\n console.print()\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy submit ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef submit(\n description: str = typer.Argument(..., help=\"The job goal in plain English.\"),\n title: str = typer.Option(None, \"--title\", help=\"Job title (defaults to the description).\"),\n max_tokens: int = typer.Option(None, \"--max-tokens\", help=\"Token budget cap — the job halts if exceeded.\"),\n priority: str = typer.Option(\"NORMAL\", \"--priority\", help=\"HIGH | NORMAL | LOW.\"),\n watch: bool = typer.Option(False, \"--watch\", \"-w\", help=\"Stream the job's events live until it finishes.\"),\n):\n \"\"\"Submit a job from the terminal and (optionally) watch it run.\n\n Examples:\\n\n osy submit \"research the EV market and write a report\"\\n\n osy submit \"summarise X\" --max-tokens 50000 --watch\n \"\"\"\n import os\n import json as _json\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n body: dict = {\n \"title\": title or (description[:60] + (\"…\" if len(description) > 60 else \"\")),\n \"description\": description,\n \"priority\": priority.upper(),\n }\n if max_tokens is not None:\n body[\"max_tokens\"] = max_tokens\n\n try:\n with httpx.Client(timeout=10) as c:\n resp = c.post(f\"{api_base}/jobs\", json=body)\n resp.raise_for_status()\n job = resp.json()\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n except httpx.HTTPStatusError as exc:\n console.print(f\"[red]✗ Submit failed: {exc.response.status_code} {exc.response.text}[/red]\")\n raise typer.Exit(1)\n\n job_id = job[\"id\"]\n console.print(f\"[green]✓[/green] Job submitted [cyan]{job_id}[/cyan]\")\n console.print(f\"[dim] dashboard: http://localhost:{FRONTEND_PORT}/jobs/{job_id}[/dim]\")\n\n if not watch:\n console.print(f\"[dim] tail with: osy logs {job_id[:8]} -f[/dim]\")\n return\n\n # ── Watch: stream events until the job reaches a terminal state ──────────\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n _TERMINAL = {\"JOB_COMPLETED\", \"JOB_FAILED\", \"JOB_CANCELLED\", \"JOB_BUDGET_EXCEEDED\"}\n console.print(\"\\n[dim]Streaming events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n pubsub.subscribe(f\"events:job:{job_id}\")\n done = False\n with httpx.Client(timeout=5) as c:\n while not done:\n msg = pubsub.get_message(timeout=2.0)\n if msg and msg[\"type\"] == \"message\":\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n _print_event(ev)\n if ev.get(\"event_type\") in _TERMINAL:\n done = True\n else:\n # No event in the window — poll status so a missed terminal\n # event (job finished before we subscribed) can't hang us.\n try:\n st = c.get(f\"{api_base}/jobs/{job_id}\").json().get(\"status\")\n if st in (\"COMPLETED\", \"FAILED\", \"CANCELLED\", \"BUDGET_EXCEEDED\"):\n console.print(f\"[dim]— job {st}[/dim]\")\n done = True\n except Exception:\n pass\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n # 1. Local build — always wins in dev so edits show immediately\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n\n # 2. Bundled inside the installed wheel — offline fallback\n try:\n import osymandias as _pkg\n bundled = Path(_pkg.__file__).parent / \"frontend_dist\"\n if bundled.exists() and any(bundled.iterdir()):\n return bundled\n except Exception:\n pass\n\n # 3. GitHub asset fetch / cache\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_example_agent(dest: Path) -> None:\n dest.write_text('''\\\n# example_agent.py — runnable example generated by `osy init --example`\n#\n# Defines one tool and one agent. `osy serve` auto-discovers both, so after\n# starting the runtime you can submit a job that routes to this agent:\n#\n# osy submit \"Summarise: OSymandias is a multi-agent runtime.\" --watch\n\nfrom osymandias import osy, OsyContext\n\n\n@osy.tool\ndef word_count(text: str) -> dict:\n \"\"\"Count the words in a piece of text.\"\"\"\n return {\"words\": len(text.split())}\n\n\n@osy.agent(\"EchoAgent\", description=\"Echoes the task back with a word count.\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n \"\"\"A minimal agent: reports the word count of its task and echoes it back.\"\"\"\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"counting\"})\n wc = word_count(task)[\"words\"]\n return {\"summary\": task, \"word_count\": wc}\n''', encoding=\"utf-8\")\n"}, "cbc5c5941c55f6f8cf61b8ac711d4eab22461f7f": {"t": 1785753116.8867185, "n": 38820, "h": "684a7c788d84b6f0", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\ndef _version_callback(value: bool) -> None:\n if value:\n import osymandias\n console.print(f\"osy {osymandias.__version__}\")\n raise typer.Exit()\n\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\n\n@app.callback()\ndef _main(\n version: bool = typer.Option(\n False, \"--version\", \"-V\",\n callback=_version_callback,\n is_eager=True,\n help=\"Show version and exit.\",\n ),\n) -> None:\n pass\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# Event colouring + formatting shared by `osy logs` and `osy submit --watch`.\n_EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"JOB_BUDGET_EXCEEDED\": \"bold red\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\", \"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"TASK_AWAITING_APPROVAL\": \"yellow\", \"TASK_APPROVED\": \"green\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n}\n\n\ndef _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init(\n example: bool = typer.Option(False, \"--example\", help=\"Also scaffold a runnable example agent + tool.\"),\n):\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n # Optional runnable example (agent + tool), auto-discovered by `osy serve`.\n if example:\n example_dest = cwd / \"example_agent.py\"\n if example_dest.exists():\n console.print(\"[yellow]↳ example_agent.py already exists, skipping[/yellow]\")\n else:\n _write_example_agent(example_dest)\n console.print(\"[green]✓[/green] example_agent.py created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\")\n if example:\n console.print('[dim]Then try:[/dim] [cyan]osy submit \"Summarise: OSymandias is a multi-agent runtime.\" --watch[/cyan]')\n console.print()\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy submit ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef submit(\n description: str = typer.Argument(..., help=\"The job goal in plain English.\"),\n title: str = typer.Option(None, \"--title\", help=\"Job title (defaults to the description).\"),\n max_tokens: int = typer.Option(None, \"--max-tokens\", help=\"Token budget cap — the job halts if exceeded.\"),\n priority: str = typer.Option(\"NORMAL\", \"--priority\", help=\"HIGH | NORMAL | LOW.\"),\n watch: bool = typer.Option(False, \"--watch\", \"-w\", help=\"Stream the job's events live until it finishes.\"),\n):\n \"\"\"Submit a job from the terminal and (optionally) watch it run.\n\n Examples:\\n\n osy submit \"research the EV market and write a report\"\\n\n osy submit \"summarise X\" --max-tokens 50000 --watch\n \"\"\"\n import os\n import json as _json\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n body: dict = {\n \"title\": title or (description[:60] + (\"…\" if len(description) > 60 else \"\")),\n \"description\": description,\n \"priority\": priority.upper(),\n }\n if max_tokens is not None:\n body[\"max_tokens\"] = max_tokens\n\n try:\n with httpx.Client(timeout=10) as c:\n resp = c.post(f\"{api_base}/jobs\", json=body)\n resp.raise_for_status()\n job = resp.json()\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n except httpx.HTTPStatusError as exc:\n console.print(f\"[red]✗ Submit failed: {exc.response.status_code} {exc.response.text}[/red]\")\n raise typer.Exit(1)\n\n job_id = job[\"id\"]\n console.print(f\"[green]✓[/green] Job submitted [cyan]{job_id}[/cyan]\")\n console.print(f\"[dim] dashboard: http://localhost:{FRONTEND_PORT}/jobs/{job_id}[/dim]\")\n\n if not watch:\n console.print(f\"[dim] tail with: osy logs {job_id[:8]} -f[/dim]\")\n return\n\n # ── Watch: stream events until the job reaches a terminal state ──────────\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n _TERMINAL = {\"JOB_COMPLETED\", \"JOB_FAILED\", \"JOB_CANCELLED\", \"JOB_BUDGET_EXCEEDED\"}\n console.print(\"\\n[dim]Streaming events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n pubsub.subscribe(f\"events:job:{job_id}\")\n done = False\n with httpx.Client(timeout=5) as c:\n while not done:\n msg = pubsub.get_message(timeout=2.0)\n if msg and msg[\"type\"] == \"message\":\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n _print_event(ev)\n if ev.get(\"event_type\") in _TERMINAL:\n done = True\n else:\n # No event in the window — poll status so a missed terminal\n # event (job finished before we subscribed) can't hang us.\n try:\n st = c.get(f\"{api_base}/jobs/{job_id}\").json().get(\"status\")\n if st in (\"COMPLETED\", \"FAILED\", \"CANCELLED\", \"BUDGET_EXCEEDED\"):\n console.print(f\"[dim]— job {st}[/dim]\")\n done = True\n except Exception:\n pass\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n # 1. Local build — always wins in dev so edits show immediately\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n\n # 2. Bundled inside the installed wheel — offline fallback\n try:\n import osymandias as _pkg\n bundled = Path(_pkg.__file__).parent / \"frontend_dist\"\n if bundled.exists() and any(bundled.iterdir()):\n return bundled\n except Exception:\n pass\n\n # 3. GitHub asset fetch / cache\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_example_agent(dest: Path) -> None:\n dest.write_text('''\\\n# example_agent.py — runnable example generated by `osy init --example`\n#\n# Defines one tool and one agent. `osy serve` auto-discovers both, so after\n# starting the runtime you can submit a job that routes to this agent:\n#\n# osy submit \"Summarise: OSymandias is a multi-agent runtime.\" --watch\n\nfrom osymandias import osy, OsyContext\n\n\n@osy.tool\ndef word_count(text: str) -> dict:\n \"\"\"Count the words in a piece of text.\"\"\"\n return {\"words\": len(text.split())}\n\n\n@osy.agent(\"EchoAgent\", description=\"Echoes the task back with a word count.\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n \"\"\"A minimal agent: reports the word count of its task and echoes it back.\"\"\"\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"counting\"})\n wc = word_count(task)[\"words\"]\n return {\"summary\": task, \"word_count\": wc}\n''', encoding=\"utf-8\")\n"}, "edfe2a48408f4ea281352c0554ff43aadd94dd9d": {"t": 1785753119.4339805, "n": 37233, "h": "b42d40b7667ce8bd", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\ndef _version_callback(value: bool) -> None:\n if value:\n import osymandias\n console.print(f\"osy {osymandias.__version__}\")\n raise typer.Exit()\n\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\n\n@app.callback()\ndef _main(\n version: bool = typer.Option(\n False, \"--version\", \"-V\",\n callback=_version_callback,\n is_eager=True,\n help=\"Show version and exit.\",\n ),\n) -> None:\n pass\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# Event colouring + formatting shared by `osy logs` and `osy submit --watch`.\n_EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"JOB_BUDGET_EXCEEDED\": \"bold red\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\", \"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"TASK_AWAITING_APPROVAL\": \"yellow\", \"TASK_APPROVED\": \"green\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n}\n\n\ndef _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy submit ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef submit(\n description: str = typer.Argument(..., help=\"The job goal in plain English.\"),\n title: str = typer.Option(None, \"--title\", help=\"Job title (defaults to the description).\"),\n max_tokens: int = typer.Option(None, \"--max-tokens\", help=\"Token budget cap — the job halts if exceeded.\"),\n priority: str = typer.Option(\"NORMAL\", \"--priority\", help=\"HIGH | NORMAL | LOW.\"),\n watch: bool = typer.Option(False, \"--watch\", \"-w\", help=\"Stream the job's events live until it finishes.\"),\n):\n \"\"\"Submit a job from the terminal and (optionally) watch it run.\n\n Examples:\\n\n osy submit \"research the EV market and write a report\"\\n\n osy submit \"summarise X\" --max-tokens 50000 --watch\n \"\"\"\n import os\n import json as _json\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n body: dict = {\n \"title\": title or (description[:60] + (\"…\" if len(description) > 60 else \"\")),\n \"description\": description,\n \"priority\": priority.upper(),\n }\n if max_tokens is not None:\n body[\"max_tokens\"] = max_tokens\n\n try:\n with httpx.Client(timeout=10) as c:\n resp = c.post(f\"{api_base}/jobs\", json=body)\n resp.raise_for_status()\n job = resp.json()\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n except httpx.HTTPStatusError as exc:\n console.print(f\"[red]✗ Submit failed: {exc.response.status_code} {exc.response.text}[/red]\")\n raise typer.Exit(1)\n\n job_id = job[\"id\"]\n console.print(f\"[green]✓[/green] Job submitted [cyan]{job_id}[/cyan]\")\n console.print(f\"[dim] dashboard: http://localhost:{FRONTEND_PORT}/jobs/{job_id}[/dim]\")\n\n if not watch:\n console.print(f\"[dim] tail with: osy logs {job_id[:8]} -f[/dim]\")\n return\n\n # ── Watch: stream events until the job reaches a terminal state ──────────\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n _TERMINAL = {\"JOB_COMPLETED\", \"JOB_FAILED\", \"JOB_CANCELLED\", \"JOB_BUDGET_EXCEEDED\"}\n console.print(\"\\n[dim]Streaming events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n pubsub.subscribe(f\"events:job:{job_id}\")\n done = False\n with httpx.Client(timeout=5) as c:\n while not done:\n msg = pubsub.get_message(timeout=2.0)\n if msg and msg[\"type\"] == \"message\":\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n _print_event(ev)\n if ev.get(\"event_type\") in _TERMINAL:\n done = True\n else:\n # No event in the window — poll status so a missed terminal\n # event (job finished before we subscribed) can't hang us.\n try:\n st = c.get(f\"{api_base}/jobs/{job_id}\").json().get(\"status\")\n if st in (\"COMPLETED\", \"FAILED\", \"CANCELLED\", \"BUDGET_EXCEEDED\"):\n console.print(f\"[dim]— job {st}[/dim]\")\n done = True\n except Exception:\n pass\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n # 1. Local build — always wins in dev so edits show immediately\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n\n # 2. Bundled inside the installed wheel — offline fallback\n try:\n import osymandias as _pkg\n bundled = Path(_pkg.__file__).parent / \"frontend_dist\"\n if bundled.exists() and any(bundled.iterdir()):\n return bundled\n except Exception:\n pass\n\n # 3. GitHub asset fetch / cache\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "75c34554dd33566e0da9f19348811b377276ca46": {"t": 1785753120.8814182, "n": 33248, "h": "54a626eb93e6343b", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\ndef _version_callback(value: bool) -> None:\n if value:\n import osymandias\n console.print(f\"osy {osymandias.__version__}\")\n raise typer.Exit()\n\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\n\n@app.callback()\ndef _main(\n version: bool = typer.Option(\n False, \"--version\", \"-V\",\n callback=_version_callback,\n is_eager=True,\n help=\"Show version and exit.\",\n ),\n) -> None:\n pass\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n _EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\",\"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n }\n\n def _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n # Pick most informative payload key\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n # 1. Local build — always wins in dev so edits show immediately\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n\n # 2. Bundled inside the installed wheel — offline fallback\n try:\n import osymandias as _pkg\n bundled = Path(_pkg.__file__).parent / \"frontend_dist\"\n if bundled.exists() and any(bundled.iterdir()):\n return bundled\n except Exception:\n pass\n\n # 3. GitHub asset fetch / cache\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "905f69f42dbf7ea75d66ce7975e90b4fea408f1a": {"t": 1785753123.3846285, "n": 33197, "h": "f3981edc24365372", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\ndef _version_callback(value: bool) -> None:\n if value:\n import osymandias\n console.print(f\"osy {osymandias.__version__}\")\n raise typer.Exit()\n\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\n\n@app.callback()\ndef _main(\n version: bool = typer.Option(\n False, \"--version\", \"-V\",\n callback=_version_callback,\n is_eager=True,\n help=\"Show version and exit.\",\n ),\n) -> None:\n pass\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n _EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\",\"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n }\n\n def _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n # Pick most informative payload key\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n # 1. Bundled inside the installed wheel — preferred, works offline\n try:\n import osymandias as _pkg\n bundled = Path(_pkg.__file__).parent / \"frontend_dist\"\n if bundled.exists() and any(bundled.iterdir()):\n return bundled\n except Exception:\n pass\n\n # 2. GitHub asset fetch / local dev build\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "af3c5968d010af21572d5172cb6383419cfd34de": {"t": 1785753125.307701, "n": 32807, "h": "67aa8026ebde1f7e", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n _EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\",\"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n }\n\n def _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n # Pick most informative payload key\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n # 1. Bundled inside the installed wheel — preferred, works offline\n try:\n import osymandias as _pkg\n bundled = Path(_pkg.__file__).parent / \"frontend_dist\"\n if bundled.exists() and any(bundled.iterdir()):\n return bundled\n except Exception:\n pass\n\n # 2. GitHub asset fetch / local dev build\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "6cd074d109e7c59e7cddf5b7c4b4944ff01fc559": {"t": 1785753126.804402, "n": 32465, "h": "da9b190082f63c35", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy logs ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef logs(\n job_id: str = typer.Argument(None, help=\"Job ID (or prefix) to tail. Omit for all recent events.\"),\n follow: bool = typer.Option(False, \"--follow\", \"-f\", help=\"Subscribe to Redis and stream new events in real time.\"),\n limit: int = typer.Option(50, \"--limit\", \"-n\", help=\"Number of past events to show (default 50).\"),\n event_type: str = typer.Option(None, \"--type\", \"-t\", help=\"Filter by event type (e.g. TASK_PROGRESS).\"),\n):\n \"\"\"Tail events for a job or the global event stream.\n\n Examples:\\n\n osy logs # last 50 events across all jobs\\n\n osy logs # last 50 events for a specific job\\n\n osy logs -f # live-stream events as they arrive\\n\n osy logs -f -t TASK_PROGRESS # live-stream only TASK_PROGRESS\n \"\"\"\n import os\n import httpx\n from pathlib import Path\n\n cwd = Path.cwd()\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n api_base = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n\n # ── Resolve full job ID if a prefix was given ────────────────────────────\n resolved_job_id: str | None = None\n if job_id:\n try:\n with httpx.Client(timeout=5) as c:\n jobs = c.get(f\"{api_base}/jobs\", params={\"limit\": 200}).json()\n matches = [j for j in jobs if j[\"id\"].startswith(job_id)]\n if not matches:\n console.print(f\"[red]✗ No job found matching '{job_id}'[/red]\")\n raise typer.Exit(1)\n resolved_job_id = matches[0][\"id\"]\n if len(matches) > 1:\n console.print(f\"[yellow]⚠ Multiple jobs match '{job_id}' — using {resolved_job_id[:8]}[/yellow]\")\n except httpx.ConnectError:\n console.print(f\"[red]✗ Cannot reach {api_base} — is `osy serve` running?[/red]\")\n raise typer.Exit(1)\n\n # ── Print past events ─────────────────────────────────────────────────────\n _EV_COLOR = {\n \"JOB_CREATED\": \"cyan\", \"JOB_STARTED\": \"cyan\",\n \"JOB_COMPLETED\": \"green\", \"JOB_FAILED\": \"red\", \"JOB_CANCELLED\": \"yellow\",\n \"TASK_CREATED\": \"cyan\", \"TASK_STARTED\": \"blue\",\n \"TASK_COMPLETED\": \"green\",\"TASK_FAILED\": \"red\", \"TASK_PROGRESS\": \"yellow\",\n \"AGENT_RUNNING\": \"blue\", \"AGENT_TERMINATED\": \"green\",\n \"TOOL_CALL_STARTED\": \"yellow\", \"TOOL_CALL_COMPLETED\": \"green\",\n \"LLM_CALL_STARTED\": \"dim\", \"LLM_CALL_COMPLETED\": \"dim\",\n \"PLANNER_FALLBACK\": \"bold red\",\n }\n\n def _print_event(ev: dict) -> None:\n ts = ev.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n etype = ev.get(\"event_type\", \"EVENT\")\n color = _EV_COLOR.get(etype, \"white\")\n payload = ev.get(\"payload\") or {}\n task_id = ev.get(\"task_id\", \"\")\n tid = f\"[dim]{task_id[:8]}[/dim] \" if task_id else \"\"\n # Pick most informative payload key\n detail = (\n payload.get(\"title\") or payload.get(\"tool_name\") or\n payload.get(\"agent_type\") or payload.get(\"step\") or\n payload.get(\"message\") or payload.get(\"reason\") or \"\"\n )\n detail_str = f\" [dim]{detail}[/dim]\" if detail else \"\"\n console.print(f\"[dim]{ts}[/dim] {tid}[{color}]{etype}[/{color}]{detail_str}\")\n\n try:\n with httpx.Client(timeout=10) as c:\n params: dict = {\"limit\": limit}\n if resolved_job_id:\n params[\"job_id\"] = resolved_job_id\n if event_type:\n params[\"event_type\"] = event_type\n resp = c.get(f\"{api_base}/events\", params=params)\n resp.raise_for_status()\n past = list(reversed(resp.json())) # oldest first\n except Exception as exc:\n console.print(f\"[red]✗ Failed to fetch events: {exc}[/red]\")\n raise typer.Exit(1)\n\n for ev in past:\n _print_event(ev)\n\n if not follow:\n console.print(f\"\\n[dim]{len(past)} event(s). Use -f to stream live.[/dim]\")\n return\n\n # ── Live stream via Redis pub/sub ─────────────────────────────────────────\n import json as _json\n import redis as _redis\n redis_url = os.environ.get(\"OSY_REDIS_URL\", \"redis://localhost:47763/0\")\n\n console.print(f\"\\n[dim]Streaming live events (Ctrl+C to stop)…[/dim]\")\n try:\n r = _redis.from_url(redis_url, decode_responses=True)\n pubsub = r.pubsub(ignore_subscribe_messages=True)\n channel = f\"events:job:{resolved_job_id}\" if resolved_job_id else \"events:global\"\n pubsub.subscribe(channel)\n for msg in pubsub.listen():\n if msg[\"type\"] != \"message\":\n continue\n try:\n ev = _json.loads(msg[\"data\"])\n except Exception:\n continue\n if event_type and ev.get(\"event_type\") != event_type:\n continue\n _print_event(ev)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Stopped.[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ Redis error: {exc}[/red]\")\n raise typer.Exit(1)\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "53e0d584bbbed0180fe17be7e80e968acea4754c": {"t": 1785753128.274524, "n": 27234, "h": "46c4e2ead70e7337", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\n# Auth (optional) — protect the API with a static key.\n# When set, all /api/v1/* requests must include:\n# Authorization: Bearer or X-Api-Key: \n# Leave empty (or omit) to disable auth entirely.\n# OSY_API_KEY=\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "33660799c644b429fbf168eace3d9e55ef0fba9f": {"t": 1785753130.7687504, "n": 27008, "h": "4982cbee67ef0461", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent Celery worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n f\"--concurrency={concurrency}\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy workers ─────────────────────────────────────────────────────────────\n\n@app.command()\ndef workers(\n queues: str = typer.Option(\n \"agents,tools,evaluator\",\n \"--queues\",\n help=\"Comma-separated list of Celery queues to consume (default: agents,tools,evaluator).\",\n envvar=\"OSY_WORKER_QUEUES\",\n ),\n concurrency: int = typer.Option(\n 4, \"--concurrency\",\n help=\"Number of concurrent worker slots (default 4).\",\n envvar=\"OSY_WORKER_CONCURRENCY\",\n ),\n loglevel: str = typer.Option(\n \"warning\", \"--loglevel\",\n help=\"Celery log level (debug|info|warning|error).\",\n ),\n):\n \"\"\"Start additional Celery workers for horizontal scaling.\n\n Run this on any machine that can reach the same RabbitMQ and Redis\n instances (set OSY_RABBITMQ_URL and OSY_REDIS_URL in the environment\n or in a local .env file). The API server does NOT need to run on\n the same machine.\n\n Example — two extra worker nodes:\\n\n # node-2\\n\n OSY_RABBITMQ_URL=amqp://... OSY_REDIS_URL=redis://... osy workers\\n\\n\n # node-3 (agents only, 8 slots)\\n\n osy workers --queues agents --concurrency 8\n \"\"\"\n cwd = Path.cwd()\n\n # Load .env if present\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n\n console.print(f\"\\n[bold cyan]osy workers[/bold cyan] queues=[cyan]{queues}[/cyan] concurrency=[cyan]{concurrency}[/cyan]\\n\")\n\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n cmd = [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n f\"--loglevel={loglevel}\",\n f\"--concurrency={concurrency}\",\n f\"--queues={queues}\",\n ]\n\n console.print(f\"[dim]Starting worker: {' '.join(cmd[2:])}[/dim]\")\n try:\n subprocess.run(cmd, check=True)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Workers stopped.[/dim]\")\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\n# Scaling — number of concurrent Celery worker slots on this node.\n# Increase for CPU-bound workloads; run `osy workers` on additional\n# machines to scale horizontally.\n# OSY_WORKER_CONCURRENCY=4\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "d91ffb5a549a4ef8ae51e8d15fab00e74a325920": {"t": 1785753133.2215757, "n": 24569, "h": "0196a2caf1cec644", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve(\n no_docker: bool = typer.Option(\n False, \"--no-docker\",\n help=\"Skip Docker — connect to externally managed services via OSY_* env vars.\",\n envvar=\"OSY_NO_DOCKER\",\n ),\n):\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Load .env first so OSY_NO_DOCKER and service URLs are available\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # Re-read OSY_NO_DOCKER after .env load (envvar= on the Option handles CLI,\n # but load_dotenv runs after typer parses args, so we check again here)\n import os as _os\n if not no_docker and _os.environ.get(\"OSY_NO_DOCKER\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n no_docker = True\n\n if no_docker:\n console.print(\"[dim] mode: [bold]no-docker[/bold] — using external services[/dim]\")\n\n # 2. Docker infra (skipped in no-docker mode)\n compose_env: dict = {}\n compose_path = None\n with_frontend = False\n\n if no_docker:\n # Verify that the external services are reachable before starting workers\n _check_external_services()\n else:\n # 2a. Check Docker daemon\n _require_docker()\n\n # 2b. Resolve compose file\n compose_path = _resolve_compose(cwd)\n\n # 2c. Resolve frontend + nginx\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 2d. Start infra containers\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 3. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 4. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 4b. Discover @osy.agent functions\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 5. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 6. Register @osy.tool tools in DB\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 7. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 8. Start nginx (Docker mode only — no-docker users manage their own proxy)\n if with_frontend and compose_path:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 9. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n elif no_docker:\n lines.append(f\" Dashboard serve frontend/out with your own proxy\", style=\"dim\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker stop.[/dim]\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n _kill_local_processes()\n if not _docker_available():\n console.print(\"[dim]No Docker — local processes stopped.[/dim]\")\n return\n try:\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n except SystemExit:\n console.print(\"[dim]No compose file found — skipping Docker down.[/dim]\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n console.print(\" Or use [cyan]osy serve --no-docker[/cyan] to connect to external services.\")\n raise typer.Exit(1)\n\n\ndef _check_external_services() -> None:\n \"\"\"Verify that external Postgres, Redis, and RabbitMQ are reachable.\"\"\"\n import os\n import socket\n import urllib.parse\n\n services = {\n \"postgres\": os.environ.get(\"OSY_POSTGRES_URL\", \"\"),\n \"redis\": os.environ.get(\"OSY_REDIS_URL\", \"\"),\n \"rabbitmq\": os.environ.get(\"OSY_RABBITMQ_URL\", \"\"),\n }\n\n failures: list[str] = []\n for name, url in services.items():\n if not url:\n console.print(f\"[yellow]⚠ {name}: OSY_{name.upper()}_URL not set — skipping check[/yellow]\")\n continue\n try:\n # Strip scheme variants to get host:port\n normalized = url\n for prefix in (\"postgresql+asyncpg://\", \"postgresql+psycopg2://\",\n \"postgresql://\", \"redis://\", \"amqp://\"):\n if normalized.startswith(prefix):\n normalized = \"tcp://\" + normalized[len(prefix):]\n break\n parsed = urllib.parse.urlparse(normalized)\n host = parsed.hostname or \"localhost\"\n port = parsed.port or {\"postgres\": 5432, \"redis\": 6379, \"rabbitmq\": 5672}[name]\n sock = socket.create_connection((host, port), timeout=4)\n sock.close()\n console.print(f\"[green]✓[/green] {name} [dim]{host}:{port}[/dim]\")\n except Exception as exc:\n console.print(f\"[red]✗ {name}[/red] [dim]{exc}[/dim]\")\n failures.append(name)\n\n if failures:\n console.print(f\"\\n[red bold]Cannot reach: {', '.join(failures)}[/red bold]\")\n console.print(\" Update OSY_* URLs in your [cyan].env[/cyan] and ensure the services are running.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _docker_available() -> bool:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=5)\n return True\n except Exception:\n return False\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure — managed by osy serve (Docker).\n# To use external/managed services instead of Docker, set OSY_NO_DOCKER=1\n# and point these URLs to your own instances.\n# OSY_NO_DOCKER=1\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "c30cdd1c2b8570f7b25d8b6ba2be6a7377704908": {"t": 1785753134.5666833, "n": 20921, "h": "53627ef520ac2611", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Resolve frontend + nginx for Docker\n import os as _os\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n with_frontend = False\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 5. Start infra (nginx starts LAST, after FastAPI is ready)\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 7b. Discover @osy.agent functions (uses agent_modules config or auto-scan)\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 10b. Start nginx now that FastAPI is up — dashboard will be immediately usable\n if with_frontend:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n _kill_local_processes()\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n _kill_local_processes()\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n # Force-remove containers first so named volumes are not \"in use\"\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--timeout\", \"5\"],\n check=False,\n capture_output=True,\n )\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _kill_local_processes() -> None:\n \"\"\"Kill any local Python processes still bound to osy ports (API + tool server).\"\"\"\n import signal\n ports = [RUNTIME_PORT, TOOL_SERVER_PORT]\n killed = []\n try:\n import psutil\n for proc in psutil.process_iter([\"pid\", \"connections\"]):\n try:\n for conn in proc.connections(kind=\"inet\"):\n if conn.laddr.port in ports and conn.status == \"LISTEN\":\n proc.kill()\n killed.append(proc.pid)\n break\n except (psutil.NoSuchProcess, psutil.AccessDenied):\n pass\n except ImportError:\n # psutil not available — fall back to platform-specific approach\n if sys.platform == \"win32\":\n for port in ports:\n result = subprocess.run(\n [\"netstat\", \"-ano\"],\n capture_output=True, text=True,\n )\n for line in result.stdout.splitlines():\n if f\":{port} \" in line and \"LISTENING\" in line:\n parts = line.split()\n pid = parts[-1]\n if pid.isdigit():\n subprocess.run([\"taskkill\", \"/F\", \"/PID\", pid], capture_output=True)\n killed.append(int(pid))\n else:\n for port in ports:\n subprocess.run(\n [\"fuser\", \"-k\", f\"{port}/tcp\"],\n capture_output=True,\n )\n if killed:\n console.print(f\"[dim] Stopped local processes: {killed}[/dim]\")\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n # Alembic env.py uses a sync engine — swap asyncpg for psycopg2 so\n # migrations work on all platforms without an asyncio event loop.\n sync_url = db_url.replace(\"postgresql+asyncpg://\", \"postgresql+psycopg2://\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", sync_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "e716650cd49a4ace06719de9a3d63a0a30c31fbc": {"t": 1785753137.234695, "n": 18766, "h": "19ed14604ea5783b", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n # Write osymandias.toml config stub\n osy_toml_dest = cwd / \"osymandias.toml\"\n if osy_toml_dest.exists():\n console.print(f\"[yellow]↳ osymandias.toml already exists, skipping[/yellow]\")\n else:\n osy_toml_dest.write_text(\n \"# OSymandias project config\\n\"\n \"# List dotted module paths containing @osy.agent decorated functions.\\n\"\n \"# If omitted, osy serve will auto-scan all .py files in this directory.\\n\"\n \"# agent_modules = [\\\"myapp.agents\\\", \\\"myapp.crews\\\"]\\n\"\n )\n console.print(\"[green]✓[/green] osymandias.toml created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Resolve frontend + nginx for Docker\n import os as _os\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n with_frontend = False\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 5. Start infra (nginx starts LAST, after FastAPI is ready)\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 7b. Discover @osy.agent functions (uses agent_modules config or auto-scan)\n from osymandias.discovery import discover_agents\n from osymandias.decorator import _AGENT_REGISTRY\n agent_count = discover_agents(cwd)\n if agent_count:\n console.print(f\"[dim]✓ {agent_count} external agent(s) discovered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 10b. Start nginx now that FastAPI is up — dashboard will be immediately usable\n if with_frontend:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", db_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "ab4f2400b27e27a527728275180c28f5a292a536": {"t": 1785753138.5401266, "n": 17827, "h": "a79b3116b6d7b237", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Resolve frontend + nginx for Docker\n import os as _os\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n with_frontend = False\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 5. Start infra (nginx starts LAST, after FastAPI is ready)\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n env=compose_env, check=True,\n )\n _wait_healthy(compose_path)\n console.print(f\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 10b. Start nginx now that FastAPI is up — dashboard will be immediately usable\n if with_frontend:\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"--profile\", \"frontend\", \"up\", \"-d\", \"nginx\"],\n env=compose_env, check=True,\n )\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n from rich.panel import Panel\n from rich.text import Text\n lines = Text()\n lines.append(f\" Tool server \", style=\"dim\")\n lines.append(f\"http://localhost:{TOOL_SERVER_PORT}\", style=\"cyan\")\n lines.append(f\" ({len(_TOOL_REGISTRY)} tools)\\n\", style=\"dim\")\n lines.append(f\" API \", style=\"dim\")\n lines.append(f\"http://localhost:{RUNTIME_PORT}/api/v1\", style=\"cyan\")\n lines.append(\"\\n\")\n if with_frontend:\n lines.append(f\" Dashboard \", style=\"dim\")\n lines.append(f\"http://localhost:{FRONTEND_PORT}\", style=\"bold cyan\")\n else:\n lines.append(f\" Dashboard cd frontend && npm run dev\", style=\"dim\")\n lines.append(\"\\n\\n\")\n lines.append(\" Press Ctrl+C to stop.\", style=\"dim\")\n console.print(Panel(lines, title=\"[green]OSymandias ready[/green]\", border_style=\"green\", padding=(0, 1)))\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"OSY_POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", db_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nOSY_POSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nOSY_REDIS_URL=redis://localhost:47763/0\nOSY_RABBITMQ_URL=amqp://guest:guest@localhost:47764/\nOSY_QDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nOSY_CORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nOSY_TOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location ~ ^/jobs/[^/]+$ {{\n try_files $uri $uri.html /jobs/_.html;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "ea58ae630823b85c8ed59af366b969bc7ec36e6c": {"t": 1785753139.8619971, "n": 17343, "h": "b2723d9f40698f95", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Resolve frontend + nginx for Docker\n import os as _os\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n with_frontend = False\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n console.print(f\"[dim]✓ Frontend ready → http://localhost:{FRONTEND_PORT}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 5. Start infra (+ nginx if frontend available)\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n compose_cmd = [\"docker\", \"compose\", \"-f\", str(compose_path)]\n if with_frontend:\n compose_cmd += [\"--profile\", \"frontend\"]\n compose_cmd += [\"up\", \"-d\"]\n subprocess.run(compose_cmd, env=compose_env, check=True)\n _wait_healthy(compose_path)\n infra_note = \"postgres · redis · rabbitmq · qdrant\" + (\" · nginx\" if with_frontend else \"\")\n console.print(f\"[green]✓[/green] Infrastructure [dim]{infra_note}[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n console.print(f\"\\n[green]✓[/green] Tool server [cyan]http://localhost:{TOOL_SERVER_PORT}[/cyan] [dim]({len(_TOOL_REGISTRY)} tools)[/dim]\")\n console.print(f\"[green]✓[/green] Runtime API [cyan]http://localhost:{RUNTIME_PORT}/api/v1[/cyan]\")\n if with_frontend:\n console.print(f\"[green]✓[/green] Dashboard [cyan]http://localhost:{FRONTEND_PORT}[/cyan]\")\n else:\n console.print(f\"[dim] Dashboard: cd frontend && npm run dev[/dim]\")\n console.print(\"\\n[dim]Press Ctrl+C to stop.[/dim]\\n\")\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:47762/osymandias\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", db_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nPOSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:47762/osymandias\nREDIS_URL=redis://localhost:47763/0\nRABBITMQ_URL=amqp://guest:guest@localhost:47764/\nQDRANT_URL=http://localhost:47766\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nCORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nTOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"47762:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"47763:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"47764:5672\", \"47765:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"47766:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "c3aa67a4e5a2de8213d196f46203c197b427b4ec": {"t": 1785753141.172075, "n": 17334, "h": "9d9bad6c6d6aaea9", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nNGINX_CONF_FILENAME = \"OSY.nginx.conf\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"osy_tools.py\"\nTOOL_SERVER_PORT = 47761 # internal tool server\nRUNTIME_PORT = 47760 # FastAPI\nFRONTEND_PORT = 47759 # nginx\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"ollama\", \"openai\", \"anthropic\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"ollama\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Fetch OSY.nginx.conf\n nginx_dest = cwd / NGINX_CONF_FILENAME\n if nginx_dest.exists():\n console.print(f\"[yellow]↳ {NGINX_CONF_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {NGINX_CONF_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import ensure_nginx_conf\n ensure_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch nginx config: {exc}[/yellow]\")\n _write_fallback_nginx_conf(nginx_dest)\n console.print(f\"[green]✓[/green] {NGINX_CONF_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Resolve frontend + nginx for Docker\n import os as _os\n compose_env = {**_os.environ}\n frontend_dir = _resolve_frontend_dir(cwd)\n with_frontend = False\n if frontend_dir:\n nginx_conf = _resolve_nginx_conf(cwd)\n if nginx_conf:\n compose_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n compose_env[\"OSY_NGINX_CONF\"] = str(nginx_conf)\n with_frontend = True\n console.print(f\"[dim]✓ Frontend ready → http://localhost:{FRONTEND_PORT}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ {NGINX_CONF_FILENAME} not found — dashboard unavailable[/yellow]\")\n else:\n console.print(f\"[yellow]⚠ No frontend build — run [cyan]npm run dev[/cyan] in frontend/ for the dashboard[/yellow]\")\n\n # 5. Start infra (+ nginx if frontend available)\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n compose_cmd = [\"docker\", \"compose\", \"-f\", str(compose_path)]\n if with_frontend:\n compose_cmd += [\"--profile\", \"frontend\"]\n compose_cmd += [\"up\", \"-d\"]\n subprocess.run(compose_cmd, env=compose_env, check=True)\n _wait_healthy(compose_path)\n infra_note = \"postgres · redis · rabbitmq · qdrant\" + (\" · nginx\" if with_frontend else \"\")\n console.print(f\"[green]✓[/green] Infrastructure [dim]{infra_note}[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n console.print(f\"\\n[green]✓[/green] Tool server [cyan]http://localhost:{TOOL_SERVER_PORT}[/cyan] [dim]({len(_TOOL_REGISTRY)} tools)[/dim]\")\n console.print(f\"[green]✓[/green] Runtime API [cyan]http://localhost:{RUNTIME_PORT}/api/v1[/cyan]\")\n if with_frontend:\n console.print(f\"[green]✓[/green] Dashboard [cyan]http://localhost:{FRONTEND_PORT}[/cyan]\")\n else:\n console.print(f\"[dim] Dashboard: cd frontend && npm run dev[/dim]\")\n console.print(\"\\n[dim]Press Ctrl+C to stop.[/dim]\\n\")\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Pause the Docker infrastructure (containers stay, data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped. Data preserved.\")\n\n\n# ─── osy down ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef down():\n \"\"\"Remove containers but keep volumes (data preserved).\"\"\"\n compose_path = _find_compose()\n console.print(\"[dim]Bringing down containers...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"down\"], check=False)\n console.print(\"[green]✓[/green] Containers removed. Volumes preserved.\")\n\n\n# ─── osy delete ──────────────────────────────────────────────────────────────\n\n@app.command()\ndef delete():\n \"\"\"Remove containers AND volumes — all data will be lost. Asks for confirmation.\"\"\"\n compose_path = _find_compose()\n\n console.print(\"[bold red]WARNING:[/bold red] This will permanently delete all OSymandias data.\")\n console.print(\"[dim] Volumes: osy_postgres · osy_redis · osy_rabbitmq · osy_qdrant[/dim]\\n\")\n\n confirm = typer.prompt(\"Type delete to confirm\")\n if confirm.strip() != \"delete\":\n console.print(\"[yellow]Aborted.[/yellow]\")\n raise typer.Exit(0)\n\n console.print(\"[dim]Removing containers and volumes...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"down\", \"--volumes\", \"--remove-orphans\"],\n check=False,\n )\n console.print(\"[green]✓[/green] Containers and volumes removed.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _find_compose() -> Path:\n \"\"\"Locate OSY.compose.yml: local dir → ~/.osy cache. Exits if not found.\"\"\"\n local = Path.cwd() / COMPOSE_FILENAME\n if local.exists():\n return local\n try:\n from osymandias.assets import compose_cache_path\n cached = compose_cache_path()\n if cached.exists():\n return cached\n except Exception:\n pass\n console.print(f\"[red]✗ {COMPOSE_FILENAME} not found.[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n return _find_compose()\n\n\ndef _resolve_frontend_dir(cwd: Path) -> \"Path | None\":\n try:\n from osymandias.assets import ensure_frontend\n return ensure_frontend()\n except Exception:\n pass\n local_out = cwd / \"frontend\" / \"out\"\n if local_out.exists() and any(local_out.iterdir()):\n return local_out\n return None\n\n\ndef _resolve_nginx_conf(cwd: Path) -> \"Path | None\":\n local = cwd / NGINX_CONF_FILENAME\n try:\n from osymandias.assets import ensure_nginx_conf\n return ensure_nginx_conf(local)\n except Exception:\n pass\n return local if local.exists() else None\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:5432/osymandias\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", db_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nPOSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:5432/osymandias\nREDIS_URL=redis://localhost:6379/0\nRABBITMQ_URL=amqp://guest:guest@localhost:5672/\nQDRANT_URL=http://localhost:6333\n\n# CORS (frontend on {FRONTEND_PORT}, direct API access on {RUNTIME_PORT})\nCORS_ORIGINS=http://localhost:{FRONTEND_PORT},http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nTOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_nginx_conf(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nserver {{\n listen 80;\n root /usr/share/nginx/html;\n charset utf-8;\n\n location ~ ^/api/v1/jobs/.+/events {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT};\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 3600s;\n }}\n\n location /api/ {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/api/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n }}\n\n location /health {{\n proxy_pass http://host.docker.internal:{RUNTIME_PORT}/health;\n }}\n\n location / {{\n try_files $uri $uri.html $uri/ /index.html;\n }}\n}}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(f\"\"\"\\\nservices:\n nginx:\n image: nginx:alpine\n profiles: [\"frontend\"]\n ports:\n - \"{FRONTEND_PORT}:80\"\n volumes:\n - ${{OSY_FRONTEND_DIR}}:/usr/share/nginx/html:ro\n - ${{OSY_NGINX_CONF}}:/etc/nginx/conf.d/default.conf:ro\n extra_hosts:\n - \"host.docker.internal:host-gateway\"\n restart: unless-stopped\n\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"5432:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"5672:5672\", \"15672:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"6333:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\n# osy_tools.py — sample file generated by `osy init`\n#\n# This file is just a starting point. You can define @osy.tool functions\n# anywhere in your project — osy serve scans all .py files automatically.\n\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\"\"\", encoding=\"utf-8\")\n"}, "fd04466ba6495a7d45fcfa885306600972dd88a4": {"t": 1785753143.0996857, "n": 12866, "h": "b35fe40ac78c0ebe", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"tools.py\"\nTOOL_SERVER_PORT = 8001\nRUNTIME_PORT = 3000\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"openai\", \"anthropic\", \"ollama\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"openai\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml from GitHub\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n console.print(\"[dim] Using bundled fallback.[/dim]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Ensure frontend cache\n console.print(\"[dim]↓ Checking frontend cache...[/dim]\")\n try:\n from osymandias.assets import ensure_frontend\n frontend_dir = ensure_frontend()\n console.print(f\"[dim]✓ Frontend ready ({frontend_dir})[/dim]\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch frontend: {exc}[/yellow]\")\n console.print(\"[dim] Dashboard may not be available.[/dim]\")\n frontend_dir = None\n\n # 5. Start infra\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n check=True,\n )\n _wait_healthy(compose_path)\n console.print(\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n import os\n runtime_env = {}\n if frontend_dir:\n runtime_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ], env=runtime_env)\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n console.print(f\"\\n[green]✓[/green] Tool server [cyan]http://localhost:{TOOL_SERVER_PORT}[/cyan] [dim]({len(_TOOL_REGISTRY)} tools)[/dim]\")\n console.print(f\"[green]✓[/green] Runtime API [cyan]http://localhost:{RUNTIME_PORT}/api/v1[/cyan]\")\n console.print(f\"[green]✓[/green] Dashboard [cyan]http://localhost:{RUNTIME_PORT}[/cyan]\")\n console.print(\"\\n[dim]Press Ctrl+C to stop.[/dim]\\n\")\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Stop the Docker infrastructure.\"\"\"\n cwd = Path.cwd()\n compose_path = cwd / COMPOSE_FILENAME\n if not compose_path.exists():\n from osymandias.assets import compose_cache_path\n compose_path = compose_cache_path()\n\n if not compose_path.exists():\n console.print(\"[red]No OSY.compose.yml found. Nothing to stop.[/red]\")\n raise typer.Exit(1)\n\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n local = cwd / COMPOSE_FILENAME\n if local.exists():\n return local\n\n from osymandias.assets import ensure_compose\n try:\n return ensure_compose(local)\n except Exception as exc:\n console.print(f\"[red]✗ Could not resolve {COMPOSE_FILENAME}: {exc}[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n from alembic.config import Config\n from alembic import command as alembic_command\n\n runtime_dir = Path(__file__).parent.parent / \"runtime\"\n db_url = os.environ.get(\"POSTGRES_URL\", \"postgresql+asyncpg://osy:osy@localhost:5432/osymandias\")\n\n cfg = Config()\n cfg.set_main_option(\"script_location\", str(runtime_dir / \"alembic\"))\n cfg.set_main_option(\"sqlalchemy.url\", db_url)\n\n try:\n alembic_command.upgrade(cfg, \"head\")\n except Exception as exc:\n console.print(f\"[red]✗ Migrations failed: {exc}[/red]\")\n raise typer.Exit(1)\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nPOSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:5432/osymandias\nREDIS_URL=redis://localhost:6379/0\nRABBITMQ_URL=amqp://guest:guest@localhost:5672/\nQDRANT_URL=http://localhost:6333\n\n# CORS\nCORS_ORIGINS=http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nTOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(\"\"\"\\\nservices:\n postgres:\n image: pgvector/pgvector:pg16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"5432:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\n volumes: [\"osy_redis:/data\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n retries: 10\n\n rabbitmq:\n image: rabbitmq:3-management-alpine\n ports: [\"5672:5672\", \"15672:15672\"]\n volumes: [\"osy_rabbitmq:/var/lib/rabbitmq\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant:latest\n ports: [\"6333:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null\"]\n interval: 5s\n retries: 10\n\nvolumes:\n osy_postgres:\n osy_redis:\n osy_rabbitmq:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\n\n# Add your own tools below.\n# Any function decorated with @osy.tool is automatically\n# discovered and registered when you run `osy serve`.\n\"\"\", encoding=\"utf-8\")\n"}, "c165d9a4df536536ca7fa3ebacf1c6df760f8a1e": {"t": 1785753144.436748, "n": 12263, "h": "b0431ee522f1b866", "c": "\"\"\"\nosy — OSymandias CLI\nCommands: init, serve, stop\n\"\"\"\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport typer\nfrom rich.console import Console\nfrom rich.prompt import Prompt\n\napp = typer.Typer(\n name=\"osy\",\n help=\"OSymandias — multi-agent runtime CLI\",\n add_completion=False,\n)\nconsole = Console()\n\nCOMPOSE_FILENAME = \"OSY.compose.yml\"\nENV_FILENAME = \".env\"\nTOOLS_FILENAME = \"tools.py\"\nTOOL_SERVER_PORT = 8001\nRUNTIME_PORT = 3000\n\n\n# ─── osy init ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef init():\n \"\"\"Initialise a new OSymandias project in the current directory.\"\"\"\n cwd = Path.cwd()\n\n console.print(\"\\n[bold cyan]osy init[/bold cyan]\")\n\n # LLM provider\n provider = Prompt.ask(\n \"LLM provider\",\n choices=[\"openai\", \"anthropic\", \"ollama\", \"deepseek\", \"groq\", \"gemini\"],\n default=\"openai\",\n )\n\n api_key = \"\"\n model = \"\"\n if provider == \"openai\":\n api_key = Prompt.ask(\"OpenAI API key\", password=True)\n model = \"gpt-4o\"\n elif provider == \"anthropic\":\n api_key = Prompt.ask(\"Anthropic API key\", password=True)\n model = \"claude-sonnet-4-6\"\n elif provider == \"ollama\":\n model = Prompt.ask(\"Ollama model\", default=\"llama3.2\")\n elif provider == \"deepseek\":\n api_key = Prompt.ask(\"DeepSeek API key\", password=True)\n model = \"deepseek-chat\"\n elif provider == \"groq\":\n api_key = Prompt.ask(\"Groq API key\", password=True)\n model = \"llama3-8b-8192\"\n elif provider == \"gemini\":\n api_key = Prompt.ask(\"Gemini API key\", password=True)\n model = \"gemini/gemini-1.5-flash\"\n\n # Fetch OSY.compose.yml from GitHub\n compose_dest = cwd / COMPOSE_FILENAME\n if compose_dest.exists():\n console.print(f\"[yellow]↳ {COMPOSE_FILENAME} already exists, skipping[/yellow]\")\n else:\n console.print(f\"[dim]↓ Fetching {COMPOSE_FILENAME} from GitHub...[/dim]\")\n try:\n from osymandias.assets import fetch_compose\n fetch_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch from GitHub: {exc}[/yellow]\")\n console.print(\"[dim] Using bundled fallback.[/dim]\")\n _write_fallback_compose(compose_dest)\n console.print(f\"[green]✓[/green] {COMPOSE_FILENAME} created (fallback)\")\n\n # Write .env\n env_dest = cwd / ENV_FILENAME\n if env_dest.exists():\n console.print(f\"[yellow]↳ {ENV_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_env(env_dest, provider, api_key, model)\n console.print(f\"[green]✓[/green] {ENV_FILENAME} created\")\n\n # Write sample tools.py\n tools_dest = cwd / TOOLS_FILENAME\n if tools_dest.exists():\n console.print(f\"[yellow]↳ {TOOLS_FILENAME} already exists, skipping[/yellow]\")\n else:\n _write_sample_tools(tools_dest)\n console.print(f\"[green]✓[/green] {TOOLS_FILENAME} created\")\n\n console.print(\"\\n[bold green]Ready.[/bold green] Run [cyan]osy serve[/cyan] to start.\\n\")\n\n\n# ─── osy serve ───────────────────────────────────────────────────────────────\n\n@app.command()\ndef serve():\n \"\"\"Start the full OSymandias runtime.\"\"\"\n from osymandias.process import ProcessManager\n from osymandias.discovery import discover\n from osymandias.decorator import _TOOL_REGISTRY\n\n cwd = Path.cwd()\n manager = ProcessManager()\n\n console.print(\"\\n[bold cyan]osy serve[/bold cyan]\\n\")\n\n # 1. Check Docker\n _require_docker()\n\n # 2. Load .env\n env_file = cwd / ENV_FILENAME\n if env_file.exists():\n from dotenv import load_dotenv\n load_dotenv(env_file, override=True)\n console.print(f\"[dim]✓ Loaded {ENV_FILENAME}[/dim]\")\n else:\n console.print(f\"[yellow]⚠ No {ENV_FILENAME} found — using environment variables[/yellow]\")\n\n # 3. Resolve OSY.compose.yml\n compose_path = _resolve_compose(cwd)\n\n # 4. Ensure frontend cache\n console.print(\"[dim]↓ Checking frontend cache...[/dim]\")\n try:\n from osymandias.assets import ensure_frontend\n frontend_dir = ensure_frontend()\n console.print(f\"[dim]✓ Frontend ready ({frontend_dir})[/dim]\")\n except Exception as exc:\n console.print(f\"[yellow]⚠ Could not fetch frontend: {exc}[/yellow]\")\n console.print(\"[dim] Dashboard may not be available.[/dim]\")\n frontend_dir = None\n\n # 5. Start infra\n console.print(\"[dim]↓ Starting infrastructure...[/dim]\")\n subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"up\", \"-d\"],\n check=True,\n )\n _wait_healthy(compose_path)\n console.print(\"[green]✓[/green] Infrastructure [dim]postgres · redis · rabbitmq · qdrant[/dim]\")\n\n # 6. Run migrations\n console.print(\"[dim]↓ Running database migrations...[/dim]\")\n _run_migrations()\n console.print(\"[dim]✓ Migrations applied[/dim]\")\n\n # 7. Discover @osy.tool functions\n console.print(\"[dim]↓ Scanning for @osy.tool functions...[/dim]\")\n count = discover(cwd)\n console.print(f\"[dim]✓ {len(_TOOL_REGISTRY)} tool(s) registered[/dim]\")\n\n # 8. Start tool server\n manager.start(\"tool-server\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.tool_server:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(TOOL_SERVER_PORT),\n \"--log-level\", \"warning\",\n ])\n\n # 9. Register @osy.tool tools in DB (as webhook tools pointing to tool server)\n time.sleep(1.5)\n _register_tools_in_db(_TOOL_REGISTRY)\n\n # 10. Start FastAPI runtime\n import os\n runtime_env = {}\n if frontend_dir:\n runtime_env[\"OSY_FRONTEND_DIR\"] = str(frontend_dir)\n\n manager.start(\"api\", [\n sys.executable, \"-m\", \"uvicorn\",\n \"osymandias.runtime.main:app\",\n \"--host\", \"0.0.0.0\",\n \"--port\", str(RUNTIME_PORT),\n \"--log-level\", \"warning\",\n ], env=runtime_env)\n\n # 11. Start Celery workers\n pool = \"solo\" if sys.platform == \"win32\" else \"prefork\"\n manager.start(\"workers\", [\n sys.executable, \"-m\", \"celery\",\n \"-A\", \"osymandias.runtime.workers.celery_app\",\n \"worker\",\n f\"--pool={pool}\",\n \"--loglevel=warning\",\n \"--concurrency=4\",\n ])\n\n time.sleep(2)\n console.print(f\"\\n[green]✓[/green] Tool server [cyan]http://localhost:{TOOL_SERVER_PORT}[/cyan] [dim]({len(_TOOL_REGISTRY)} tools)[/dim]\")\n console.print(f\"[green]✓[/green] Runtime API [cyan]http://localhost:{RUNTIME_PORT}/api/v1[/cyan]\")\n console.print(f\"[green]✓[/green] Dashboard [cyan]http://localhost:{RUNTIME_PORT}[/cyan]\")\n console.print(\"\\n[dim]Press Ctrl+C to stop.[/dim]\\n\")\n\n manager.wait_all()\n\n\n# ─── osy stop ────────────────────────────────────────────────────────────────\n\n@app.command()\ndef stop():\n \"\"\"Stop the Docker infrastructure.\"\"\"\n cwd = Path.cwd()\n compose_path = cwd / COMPOSE_FILENAME\n if not compose_path.exists():\n from osymandias.assets import compose_cache_path\n compose_path = compose_cache_path()\n\n if not compose_path.exists():\n console.print(\"[red]No OSY.compose.yml found. Nothing to stop.[/red]\")\n raise typer.Exit(1)\n\n console.print(\"[dim]Stopping infrastructure...[/dim]\")\n subprocess.run([\"docker\", \"compose\", \"-f\", str(compose_path), \"stop\"], check=False)\n console.print(\"[green]✓[/green] Infrastructure stopped.\")\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _require_docker() -> None:\n try:\n subprocess.run([\"docker\", \"info\"], capture_output=True, check=True, timeout=10)\n except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):\n console.print(\"[red bold]✗ Docker is not running.[/red bold]\")\n console.print(\" Install Docker from https://docker.com and start the daemon.\")\n raise typer.Exit(1)\n\n\ndef _resolve_compose(cwd: Path) -> Path:\n local = cwd / COMPOSE_FILENAME\n if local.exists():\n return local\n\n from osymandias.assets import ensure_compose\n try:\n return ensure_compose(local)\n except Exception as exc:\n console.print(f\"[red]✗ Could not resolve {COMPOSE_FILENAME}: {exc}[/red]\")\n console.print(\" Run [cyan]osy init[/cyan] first.\")\n raise typer.Exit(1)\n\n\ndef _wait_healthy(compose_path: Path, timeout: int = 90) -> None:\n services = [\"postgres\", \"redis\", \"rabbitmq\", \"qdrant\"]\n deadline = time.time() + timeout\n while time.time() < deadline:\n result = subprocess.run(\n [\"docker\", \"compose\", \"-f\", str(compose_path), \"ps\", \"--format\", \"json\"],\n capture_output=True, text=True,\n )\n if all(svc in result.stdout for svc in services):\n # crude check: all services appear in output\n time.sleep(3)\n return\n time.sleep(2)\n\n\ndef _run_migrations() -> None:\n import os\n alembic_ini = Path(__file__).parent.parent / \"runtime\" / \"alembic.ini\"\n if not alembic_ini.exists():\n return\n subprocess.run(\n [sys.executable, \"-m\", \"alembic\", \"-c\", str(alembic_ini), \"upgrade\", \"head\"],\n check=False,\n )\n\n\ndef _register_tools_in_db(registry: dict) -> None:\n if not registry:\n return\n import httpx\n base = f\"http://localhost:{TOOL_SERVER_PORT}\"\n api = f\"http://localhost:{RUNTIME_PORT}/api/v1\"\n for name, entry in registry.items():\n try:\n httpx.post(f\"{api}/tools\", json={\n \"name\": name,\n \"description\": entry.description,\n \"input_schema\": entry.parameters,\n \"output_schema\": {\"type\": \"object\"},\n \"webhook_url\": f\"{base}/tools/{name}/call\",\n }, timeout=5)\n except Exception:\n pass # runtime may still be starting; tools sync on next request\n\n\ndef _write_env(dest: Path, provider: str, api_key: str, model: str) -> None:\n key_line = \"\"\n if provider == \"openai\" and api_key:\n key_line = f\"OPENAI_API_KEY={api_key}\"\n elif provider == \"anthropic\" and api_key:\n key_line = f\"ANTHROPIC_API_KEY={api_key}\"\n elif provider == \"deepseek\" and api_key:\n key_line = f\"DEEPSEEK_API_KEY={api_key}\"\n elif provider == \"groq\" and api_key:\n key_line = f\"GROQ_API_KEY={api_key}\"\n elif provider == \"gemini\" and api_key:\n key_line = f\"GEMINI_API_KEY={api_key}\"\n\n dest.write_text(f\"\"\"\\\n# LLM\nLLM_DEFAULT_PROVIDER={provider}\nLLM_DEFAULT_MODEL={model}\n{key_line}\n\n# Infrastructure (managed by osy serve)\nPOSTGRES_URL=postgresql+asyncpg://osy:osy@localhost:5432/osymandias\nREDIS_URL=redis://localhost:6379/0\nRABBITMQ_URL=amqp://guest:guest@localhost:5672/\nQDRANT_URL=http://localhost:6333\n\n# CORS\nCORS_ORIGINS=http://localhost:{RUNTIME_PORT}\n\n# Tool server (internal)\nTOOL_SERVER_URL=http://localhost:{TOOL_SERVER_PORT}\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_fallback_compose(dest: Path) -> None:\n dest.write_text(\"\"\"\\\nservices:\n postgres:\n image: postgres:16\n environment:\n POSTGRES_USER: osy\n POSTGRES_PASSWORD: osy\n POSTGRES_DB: osymandias\n ports: [\"5432:5432\"]\n volumes: [\"osy_postgres:/var/lib/postgresql/data\"]\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U osy\"]\n interval: 5s\n timeout: 5s\n retries: 10\n\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n\n rabbitmq:\n image: rabbitmq:3-management\n ports: [\"5672:5672\", \"15672:15672\"]\n healthcheck:\n test: [\"CMD\", \"rabbitmq-diagnostics\", \"ping\"]\n interval: 10s\n retries: 10\n\n qdrant:\n image: qdrant/qdrant\n ports: [\"6333:6333\"]\n volumes: [\"osy_qdrant:/qdrant/storage\"]\n\nvolumes:\n osy_postgres:\n osy_qdrant:\n\"\"\", encoding=\"utf-8\")\n\n\ndef _write_sample_tools(dest: Path) -> None:\n dest.write_text(\"\"\"\\\nfrom osymandias import osy\n\n\n@osy.tool\ndef hello(name: str) -> dict:\n \\\"\\\"\\\"Return a greeting for the given name.\\\"\\\"\\\"\n return {\"message\": f\"Hello, {name}!\"}\n\n\n# Add your own tools below.\n# Any function decorated with @osy.tool is automatically\n# discovered and registered when you run `osy serve`.\n\"\"\", encoding=\"utf-8\")\n"}}, "DOCS_pt-br.md": {"0fb440164c3fa0e86c802eddaedb50babb94fcca": {"t": 1785746522.2552574, "n": 36154, "h": "1ce15d9ddd98a04d", "c": "# OSymandias — Documentação\n\n> Referência completa da API e guia de uso.\n\n---\n\n## Índice\n\n1. [Instalação](#instalação)\n2. [Referência da CLI](#referência-da-cli)\n - [osy --version](#osy---version)\n3. [Autenticação](#autenticação)\n4. [@osy.tool — ferramentas nativas](#osytool)\n5. [@osy.agent — agentes externos](#osyagent)\n6. [osymandias.toml](#osymandiastoml)\n7. [OsyContext](#osycontext)\n8. [Adaptadores](#adaptadores)\n - [LangChain](#langchain)\n - [CrewAI](#crewai)\n - [LlamaIndex](#llamaindex)\n - [Smolagents](#smolagents)\n - [OpenAI Agents SDK](#openai-agents-sdk)\n9. [Enviando jobs via API](#enviando-jobs-via-api)\n - [Job em linguagem natural](#job-em-linguagem-natural)\n - [Plano de tarefas explícito (`__task_plan__`)](#plano-de-tarefas-explícito-__task_plan__)\n - [Resubmissão de um job](#resubmissão-de-um-job)\n10. [Controles de produção](#controles-de-produção)\n - [Teto de tokens (budget)](#teto-de-tokens-budget)\n - [Aprovação humana](#aprovação-humana-human-in-the-loop)\n - [Webhooks de lifecycle](#webhooks-de-lifecycle)\n - [Breakdown de custo e tokens](#breakdown-de-custo-e-tokens)\n - [Trace de execução](#trace-de-execução)\n - [Cache de resposta do LLM](#cache-de-resposta-do-llm)\n11. [Páginas do dashboard](#páginas-do-dashboard)\n12. [Provedores de LLM suportados](#provedores-de-llm-suportados)\n13. [Escalabilidade](#escalabilidade)\n14. [Dependências opcionais](#dependências-opcionais)\n\n---\n\n## Instalação\n\n```bash\npip install osymandias\n```\n\n**Pré-requisitos:** Python 3.11+, Docker (utilizado para gerenciar os containers de infraestrutura).\n\nExtras opcionais por framework:\n\n```bash\npip install osymandias[langchain]\npip install osymandias[crewai]\npip install osymandias[llamaindex]\npip install osymandias[smolagents]\npip install osymandias[openai-agents]\n\n# Todos de uma vez\npip install osymandias[all]\n```\n\n---\n\n## Referência da CLI\n\n### `osy --version`\n\nExibe a versão instalada e encerra.\n\n```bash\nosy --version # osy 1.1.1\nosy -V\n```\n\n---\n\n### `osy init`\n\nInicializa um novo projeto OSymandias no diretório atual.\n\n```\nosy init\n```\n\nPrompts interativos solicitam o provedor de LLM e a chave de API. Cria:\n\n| Arquivo | Finalidade |\n|---|---|\n| `OSY.compose.yml` | Docker Compose com todos os containers de infraestrutura |\n| `OSY.nginx.conf` | Config do Nginx que serve o dashboard na porta 47759 |\n| `.env` | Chaves dos provedores de LLM e configuração do runtime |\n| `osy_tools.py` | Exemplo de arquivo com `@osy.tool` para começar |\n| `osymandias.toml` | Config do projeto (agent_modules comentado) |\n\nUse `--example` para também gerar o `example_agent.py` — um par `@osy.tool` + `@osy.agent` runnable, auto-descoberto pelo `osy serve`. O comando então imprime uma dica de `osy submit` pronta pra rodar, levando você de \"instalei\" a \"job rodando\" em três comandos.\n\nSeguro para re-executar — arquivos existentes são ignorados.\n\n---\n\n### `osy serve`\n\nInicia o runtime completo.\n\n```\nosy serve [--no-docker] [--concurrency N]\n```\n\nSobe os containers Docker, executa as migrações do banco, descobre os callables `@osy.tool` e `@osy.agent`, e inicia:\n\n| Serviço | Porta |\n|---|---|\n| Dashboard (nginx) | `47759` |\n| Backend FastAPI | `47760` |\n| Servidor interno de ferramentas | `47761` |\n\n**Modo `--no-docker`** — ignora o Docker e conecta a serviços gerenciados externamente:\n\n```bash\n# No .env — descomente e atualize as URLs para suas instâncias:\n# OSY_NO_DOCKER=1\n# OSY_POSTGRES_URL=postgresql+asyncpg://user:pass@meu-db.exemplo.com:5432/osymandias\n# OSY_REDIS_URL=redis://meu-redis.exemplo.com:6379/0\n# OSY_RABBITMQ_URL=amqp://user:pass@meu-rabbit.exemplo.com:5672/\n# OSY_QDRANT_URL=http://meu-qdrant.exemplo.com:6333\n\nosy serve --no-docker\n# ou: OSY_NO_DOCKER=1 osy serve\n```\n\n`osy serve --no-docker` verifica a conectividade com todos os serviços configurados antes de iniciar e exibe um erro claro caso algum esteja inacessível. `osy stop` e `osy down` ignoram o Docker graciosamente quando não disponível.\n\n**`--concurrency N`** — número de slots simultâneos do worker Celery neste nó (padrão: 4). Também lê de `OSY_WORKER_CONCURRENCY` no `.env`.\n\n---\n\n### `osy logs`\n\nAcompanha eventos de um job específico ou do stream global.\n\n```\nosy logs [JOB_ID] [--follow] [--limit N] [--type TIPO_EVENTO]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `JOB_ID` | — | ID do job ou prefixo sem ambiguidade. Omita para todos os jobs. |\n| `--follow` / `-f` | desligado | Assina o pub/sub do Redis e transmite eventos ao vivo |\n| `--limit N` / `-n N` | `50` | Número de eventos passados a exibir antes do stream |\n| `--type` / `-t` | — | Filtra por tipo de evento (ex.: `TASK_PROGRESS`, `TOOL_CALL_STARTED`) |\n\n```bash\nosy logs # últimos 50 eventos de todos os jobs\nosy logs abc123 # últimos 50 eventos do job abc123\nosy logs abc123 -f # stream ao vivo de todos os eventos do job\nosy logs abc123 -f -t TASK_PROGRESS # stream ao vivo apenas de eventos de progresso\n```\n\n---\n\n### `osy submit`\n\nSubmete um job pelo terminal — sem curl, sem dashboard.\n\n```\nosy submit \"\" [--watch] [--max-tokens N] [--priority P] [--title T]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `` | — | O objetivo do job em linguagem natural (obrigatório) |\n| `--watch` / `-w` | off | Stream ao vivo dos eventos do job até um estado terminal |\n| `--max-tokens` | — | Teto de tokens — o job para se ultrapassar |\n| `--priority` | `NORMAL` | `HIGH` · `NORMAL` · `LOW` |\n| `--title` | (objetivo) | Título do job |\n\n```bash\nosy submit \"pesquise o mercado de EV e escreva um relatório\"\nosy submit \"resuma X\" --max-tokens 50000 --watch\n```\n\nImprime o id do novo job e um link do dashboard; com `--watch`, faz stream dos eventos até o job completar, falhar ou estourar o budget.\n\n---\n\n### `osy workers`\n\nInicia workers Celery **adicionais** para escalabilidade horizontal. Sem servidor de API, sem Docker — apenas processos worker conectando ao RabbitMQ e Redis compartilhados.\n\n```\nosy workers [--queues FILAS] [--concurrency N] [--loglevel NÍVEL]\n```\n\n| Opção | Padrão | Variável de ambiente |\n|---|---|---|\n| `--queues` | `agents,tools,evaluator` | `OSY_WORKER_QUEUES` |\n| `--concurrency` | `4` | `OSY_WORKER_CONCURRENCY` |\n| `--loglevel` | `warning` | — |\n\nExecute em qualquer máquina que consiga acessar o mesmo broker/backend. Veja [Escalabilidade](#escalabilidade).\n\n---\n\n### `osy stop`\n\nPausa todos os containers sem deletar dados.\n\n```\nosy stop\n```\n\n---\n\n### `osy down`\n\nPara e remove os containers, mas mantém os volumes (dados do PostgreSQL, vetores do Qdrant).\n\n```\nosy down\n```\n\n---\n\n### `osy delete`\n\nPara e remove containers **e** volumes. Solicita confirmação antes de deletar.\n\n```\nosy delete\n```\n\n> **Atenção:** Isso deleta permanentemente todos os jobs, memória e dados de agentes.\n\n---\n\n## Autenticação\n\nO OSymandias inclui um controle opcional por chave de API estática. A autenticação está **desabilitada por padrão** — nenhuma configuração necessária para desenvolvimento local.\n\n### Habilitando a autenticação\n\nDefina `OSY_API_KEY` no `.env` (ou como variável de ambiente):\n\n```bash\n# .env\nOSY_API_KEY=minha-chave-secreta\n```\n\nReinicie com `osy serve`. Todos os endpoints `/api/v1/*` passarão a exigir a chave. `/health`, `/docs` e `/openapi.json` estão sempre isentos.\n\n### Enviando a chave\n\nQualquer um dos headers funciona:\n\n```bash\n# Header Authorization (esquema Bearer)\ncurl -H \"Authorization: Bearer minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n\n# Header X-Api-Key\ncurl -H \"X-Api-Key: minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n```\n\nRequisições sem chave válida recebem `401 Unauthorized`.\n\n### Desabilitando a autenticação\n\nRemova `OSY_API_KEY` do `.env` (ou defina como string vazia) e reinicie.\n\n---\n\n## @osy.tool\n\nRegistra qualquer função Python como uma ferramenta de agente. As ferramentas ficam disponíveis para agentes nativos via dashboard (página `/tools`).\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef minha_ferramenta(arg1: str, arg2: int = 0) -> dict:\n \"\"\"Descrição de uma linha exibida no dashboard.\"\"\"\n return {\"resultado\": ...}\n```\n\n**Regras:**\n- O nome da função se torna o nome da ferramenta.\n- A primeira linha do docstring se torna a descrição.\n- Tipos de parâmetros e valores padrão são inferidos dos type hints.\n- O tipo de retorno deve ser `dict` (ou serializável em JSON).\n\n**Exemplo — múltiplas ferramentas:**\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef buscar_banco_de_dados(query: str, limite: int = 10) -> dict:\n \"\"\"Busca no banco de dados interno de produtos. Retorna itens correspondentes.\"\"\"\n rows = db.execute(\"SELECT * FROM products WHERE ...\", (query, limite))\n return {\"itens\": [dict(r) for r in rows]}\n\n@osy.tool\ndef enviar_mensagem_slack(canal: str, texto: str) -> dict:\n \"\"\"Envia uma mensagem para um canal do Slack.\"\"\"\n slack_client.chat_postMessage(channel=canal, text=texto)\n return {\"ok\": True}\n```\n\nAs ferramentas são descobertas automaticamente — nenhuma chamada de registro necessária. Qualquer arquivo `.py` no projeto que importe `osymandias` é escaneado ao executar `osy serve`.\n\n---\n\n## @osy.agent\n\nRegistra qualquer callable Python como um agente externo do OSymandias. O agente é despachado via Celery e aparece no dashboard `/agents` com um badge colorido.\n\n```python\nfrom osymandias import osy, OsyContext\n\n@osy.agent(\"MeuAgente\")\ndef meu_agente(task: str, ctx: OsyContext) -> dict:\n ...\n return {\"resultado\": ...}\n```\n\n### Assinatura\n\n```python\nosy.agent(\n name: str,\n *,\n description: str = \"\",\n framework: str | None = None,\n llm_provider: str | None = None,\n llm_model: str | None = None,\n output_schema = None,\n input_schema = None,\n tools: list[str] | None = None,\n) -> Callable\n```\n\n### Parâmetros\n\n| Parâmetro | Tipo | Descrição |\n|---|---|---|\n| `name` | `str` | **Obrigatório.** Nome de exibição e chave de despacho. Deve ser único. |\n| `description` | `str` | Descrição legível exibida no painel de detalhes do agente. |\n| `framework` | `str \\| None` | Framework utilizado. Controla a cor do badge no registro. Valores aceitos: `\"crewai\"`, `\"langchain\"`, `\"llamaindex\"`, `\"smolagents\"`, `\"autogen\"`. Qualquer outra string é exibida como está. |\n| `llm_provider` | `str \\| None` | Provedor utilizado internamente (ex.: `\"ollama\"`, `\"openai\"`). Apenas informativo. |\n| `llm_model` | `str \\| None` | Modelo utilizado internamente (ex.: `\"qwen2.5:7b\"`). Apenas informativo. |\n| `output_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo o formato de retorno. Exibido no painel de detalhes. |\n| `input_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo a entrada esperada além de `task: str`. |\n| `tools` | `list[str] \\| None` | Nomes das ferramentas que este agente utiliza. Apenas informativo. |\n\n> **Todos os kwargs são opcionais.** O agente executa independentemente do que for declarado. Eles existem apenas para enriquecer a exibição no dashboard.\n\n### Registro adaptativo de agentes\n\nAgentes externos são automaticamente incluídos no contexto do PlannerAgent em cada job. Quando um job em linguagem natural é submetido (sem `__task_plan__`), o planner recebe a lista completa de tipos de agentes disponíveis — nativos *e* externos — e pode rotear tarefas para qualquer um deles pelo nome. Nenhuma configuração necessária: registre um agente com `@osy.agent` e o planner o descobrirá no próximo `osy serve`.\n\n```\nosy serve ← descobre callables @osy.agent, popula o banco\n↓\nPOST /jobs {\"title\": \"...\", \"description\": \"Pesquise EVs e escreva um resumo\"}\n↓\nPlannerAgent ← vê: ResearchAgent, WriterAgent, AnalystAgent,\n MeuAgentePersonalizado [langchain] (externo), ...\n↓\nCria tarefas roteadas para os agentes corretos automaticamente\n```\n\n### Assinatura do callable\n\nA função decorada deve aceitar `task: str` como primeiro argumento e retornar um `dict`. O parâmetro `ctx: OsyContext` é opcional — inclua-o para acessar memória, eventos e sub-tarefas.\n\n```python\n# Mínimo\n@osy.agent(\"EchoAgent\")\ndef echo(task: str) -> dict:\n return {\"resultado\": task}\n\n# Com contexto\n@osy.agent(\"EchoAgent\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"ecoando\"})\n return {\"resultado\": task}\n```\n\n### Exemplo completo\n\n```python\nfrom pydantic import BaseModel\nfrom osymandias import osy, OsyContext\n\nclass ResultadoPesquisa(BaseModel):\n resumo: str\n fontes: list[str]\n confianca: float\n\n@osy.agent(\n \"ResearchAgent\",\n framework=\"langchain\",\n description=\"Pesquisa na web e resume os resultados\",\n llm_provider=\"ollama\",\n llm_model=\"qwen2.5:7b\",\n output_schema=ResultadoPesquisa,\n)\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"iniciando pesquisa\"})\n chain = build_chain()\n resultado = chain.invoke(task)\n ctx.write_memory(\"output_pesquisa\", {\"resumo\": resultado})\n return {\"resumo\": resultado, \"fontes\": [], \"confianca\": 0.9}\n```\n\n---\n\n## osymandias.toml\n\nDeclara quais módulos Python contêm seus callables `@osy.agent`. Colocado na raiz do projeto (criado automaticamente pelo `osy init`).\n\n```toml\n# osymandias.toml\n\nagent_modules = [\n \"meuprojeto.agents\",\n \"meuprojeto.crews\",\n \"meuprojeto.pipelines.research\",\n]\n```\n\nAo executar `osy serve`, cada módulo é importado em ordem e todos os decoradores `@osy.agent` nesses módulos são executados, populando o registro de agentes.\n\n**Fallback:** Se `agent_modules` estiver ausente ou o arquivo não tiver entradas, `osy serve` faz fallback para escanear todos os arquivos `.py` no diretório do projeto que importam `osymandias`. Conveniente durante o desenvolvimento, mas mais lento para projetos grandes.\n\n**Alternativa:** Você também pode declarar os módulos no `pyproject.toml`:\n\n```toml\n[tool.osymandias]\nagent_modules = [\"meuprojeto.agents\"]\n```\n\n`osymandias.toml` tem prioridade sobre `pyproject.toml` se ambos existirem.\n\n---\n\n## OsyContext\n\n`OsyContext` é injetado como parâmetro `ctx` de cada callable `@osy.agent` no momento da execução. Fornece acesso à memória compartilhada do job, ao stream de eventos ao vivo e ao spawn de sub-tarefas — tudo com escopo no job atual.\n\n```python\nfrom osymandias import OsyContext\n```\n\n---\n\n### `ctx.write_memory(key, value)`\n\nEscreve um valor na memória compartilhada do job atual.\n\n```python\nctx.write_memory(key: str, value: dict) -> None\n```\n\n- Sobrescreve qualquer valor anterior na mesma chave.\n- Todos os agentes no mesmo job compartilham o mesmo namespace — um agente LangChain pode ler o que um agente CrewAI escreveu.\n- Persistido imediatamente na transação atual do banco.\n\n```python\nctx.write_memory(\"plano\", {\n \"etapas\": [\"pesquisa\", \"análise\", \"escrita\"],\n \"prioridade\": \"alta\",\n})\n```\n\n---\n\n### `ctx.read_memory(key)`\n\nLê um valor previamente escrito da memória do job.\n\n```python\nctx.read_memory(key: str) -> dict | None\n```\n\nRetorna `None` se a chave ainda não existir.\n\n```python\nplano = ctx.read_memory(\"plano\")\nif plano:\n etapas = plano[\"etapas\"]\n```\n\n---\n\n### `ctx.search_memory(query, top_k=5)`\n\nBusca semântica vetorial sobre todas as entradas de memória do job atual.\n\n```python\nctx.search_memory(query: str, top_k: int = 5) -> list[dict]\n```\n\nUtiliza embeddings do Qdrant. Útil para recuperar outputs passados relevantes sem conhecer a chave exata.\n\n```python\nentradas = ctx.search_memory(\"preços de concorrentes\", top_k=3)\nfor e in entradas:\n print(e[\"key\"], e[\"value\"])\n```\n\n---\n\n### `ctx.emit_event(event, data)`\n\nEmite um evento que é transmitido ao vivo para o feed SSE do dashboard.\n\n```python\nctx.emit_event(event: str, data: dict) -> None\n```\n\nOs eventos aparecem instantaneamente na timeline do job. Convenções comuns (não obrigatórias):\n\n| Tipo de evento | Payload típico |\n|---|---|\n| `\"TASK_PROGRESS\"` | `{\"pct\": 50, \"step\": \"analisando\"}` |\n| `\"AGENT_LOG\"` | `{\"message\": \"encontradas 12 fontes\"}` |\n| Qualquer string customizada | Qualquer dict |\n\n```python\nctx.emit_event(\"TASK_PROGRESS\", {\"pct\": 25, \"step\": \"buscando fontes\"})\nctx.emit_event(\"AGENT_LOG\", {\"message\": f\"query retornou {n} resultados\"})\n```\n\n---\n\n### `ctx.spawn_tasks(task_defs)`\n\nCria uma ou mais sub-tarefas que rodam sob a tarefa atual.\n\n```python\nctx.spawn_tasks(task_defs: list[dict]) -> list[uuid.UUID]\n```\n\nCada sub-tarefa é enfileirada imediatamente no scheduler Celery e executa em paralelo. As tarefas aparecem como uma árvore sob a tarefa pai na timeline do job.\n\n**Chaves de task_def:**\n\n| Chave | Obrigatório | Descrição |\n|---|---|---|\n| `title` | Sim | Nome de exibição da sub-tarefa |\n| `agent_type` | Não | Nome do agente registrado para despacho (padrão: `\"ResearchAgent\"`) |\n| `description` | Não | Contexto de entrada passado ao agente |\n\nRetorna uma lista de UUIDs de tarefas na mesma ordem de `task_defs`.\n\n```python\nids = ctx.spawn_tasks([\n {\n \"title\": \"Pesquisa de Mercado\",\n \"agent_type\": \"ResearchAgent\",\n \"description\": f\"Pesquise o mercado de EVs: {task}\",\n },\n {\n \"title\": \"Análise de Concorrentes\",\n \"agent_type\": \"AnalystAgent\",\n \"description\": f\"Analise os principais concorrentes de EVs para: {task}\",\n },\n])\n```\n\n---\n\n### `ctx.wait_for_tasks(task_ids, timeout=90)`\n\nBloqueia até que todas as sub-tarefas especificadas atinjam um estado terminal (concluída, falha ou cancelada).\n\n```python\nctx.wait_for_tasks(task_ids: list[uuid.UUID], timeout: int = 90) -> dict[str, dict]\n```\n\nAssina o canal pub/sub do Redis do job e acorda imediatamente quando cada tarefa é concluída — sem intervalo de polling fixo. Faz fallback para uma leitura final no banco para cobrir o caso em que uma tarefa terminou antes da assinatura ser estabelecida. Retorna resultados para todas as tarefas independentemente de sucesso ou falha — verifique a chave `\"error\"` nos dicts individuais se necessário.\n\n**Retorna:** `{título_da_tarefa: dict_resultado}`\n\nTarefas que não concluírem dentro do `timeout` em segundos são registradas como aviso; sua entrada no dict de resultado será `{}`.\n\n```python\nids = ctx.spawn_tasks([...])\nresultados = ctx.wait_for_tasks(ids, timeout=120)\n\npesquisa = resultados.get(\"Pesquisa de Mercado\", {})\nconcorrente = resultados.get(\"Análise de Concorrentes\", {})\n\nctx.write_memory(\"combinado\", {**pesquisa, **concorrente})\nreturn {\"merged\": resultados}\n```\n\n---\n\n## Adaptadores\n\nAdaptadores encapsulam objetos de frameworks de terceiros para que seu output esteja em conformidade com o tipo de retorno `dict` esperado pelo `@osy.agent`. Cada adaptador está em `osymandias.adapters.*`.\n\n---\n\n### LangChain\n\n```bash\npip install osymandias[langchain]\n```\n\n```python\nfrom osymandias.adapters.langchain import LangChainAdapter\n```\n\nEncapsula qualquer **LCEL Runnable** do LangChain (chains, pipelines `prompt | llm | parser`) ou **AgentExecutor** legado. Automaticamente anexa um `OsyCallbackHandler` que encaminha eventos `on_llm_start`, `on_tool_start`, etc. como eventos `TASK_PROGRESS` para o dashboard.\n\n```python\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_ollama import ChatOllama\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.langchain import LangChainAdapter\n\nllm = ChatOllama(model=\"qwen2.5:7b\")\nprompt = ChatPromptTemplate.from_messages([\n (\"system\", \"Você é um assistente de pesquisa.\"),\n (\"human\", \"{input}\"),\n])\nchain = prompt | llm\n\n@osy.agent(\"ResearchAgent\", framework=\"langchain\", llm_model=\"qwen2.5:7b\")\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n return LangChainAdapter(chain).run(task, ctx=ctx)\n```\n\n**`LangChainAdapter.run(task, ctx=None) → dict`**\n\nInvoca a chain com `{\"input\": task}`. Se `ctx` for fornecido, anexa o callback handler. Normaliza o resultado para `dict` automaticamente (lida com `str`, modelos Pydantic e dicts brutos).\n\n---\n\n### CrewAI\n\n```bash\npip install osymandias[crewai]\n```\n\n```python\nfrom osymandias.adapters.crewai import CrewAIAdapter\n```\n\nEncapsula um objeto `Crew`. O crew executa como uma caixa-preta — handoffs internos entre agentes dentro do crew não são rastreados individualmente no OSymandias. Use `ctx.emit_event` manualmente dentro de callbacks do CrewAI se precisar de progresso intermediário.\n\n```python\nfrom crewai import Agent, Crew, Task\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.crewai import CrewAIAdapter\n\npesquisador = Agent(role=\"Pesquisador\", goal=\"Encontrar dados precisos\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\nanalista = Agent(role=\"Analista\", goal=\"Interpretar os achados\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\n\ncrew = Crew(\n agents=[pesquisador, analista],\n tasks=[\n Task(description=\"Pesquise {task}\", agent=pesquisador),\n Task(description=\"Analise os achados\", agent=analista),\n ],\n verbose=False,\n)\n\n@osy.agent(\"AnalystCrew\", framework=\"crewai\")\ndef analyst_crew(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"crew iniciando\"})\n resultado = CrewAIAdapter(crew).run(task, ctx=ctx)\n ctx.write_memory(\"output_crew\", resultado)\n return resultado\n```\n\n**`CrewAIAdapter.run(task, ctx=None) → dict`**\n\nChama `crew.kickoff(inputs={\"task\": task})`. Normaliza `CrewOutput.raw` para `dict`.\n\n---\n\n### LlamaIndex\n\n```bash\npip install osymandias[llamaindex]\n```\n\n```python\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n```\n\nEncapsula um `QueryEngine` (usa `.query()`) ou um `ReActAgent` (usa `.chat()`). Nós fonte da recuperação são incluídos no resultado em `\"sources\"`. Imagens encontradas nos metadados dos nós fonte são coletadas em `\"_media\"` para renderização multimodal no dashboard.\n\n```python\nfrom llama_index.core import VectorStoreIndex, SimpleDirectoryReader\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n\ndocs = SimpleDirectoryReader(\"./data\").load_data()\nindex = VectorStoreIndex.from_documents(docs)\nengine = index.as_query_engine()\n\n@osy.agent(\"RAGAgent\", framework=\"llamaindex\", description=\"Responde perguntas a partir de documentos locais\")\ndef rag_agent(task: str, ctx: OsyContext) -> dict:\n return LlamaIndexAdapter(engine).run(task, ctx=ctx)\n```\n\n**`LlamaIndexAdapter.run(task, ctx=None) → dict`**\n\nRetorna `{\"output\": str, \"sources\": list[str], \"_media\": list[dict]}`.\n\n---\n\n### Smolagents\n\n```bash\npip install osymandias[smolagents]\n```\n\n```python\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n```\n\nEncapsula qualquer agente HuggingFace Smolagents. Se o agente retornar um `matplotlib.figure.Figure` ou `PIL.Image`, é codificado em base64 e colocado em `\"_media\"` para renderização no dashboard.\n\n```python\nfrom smolagents import CodeAgent, HfApiModel\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n\nagent = CodeAgent(\n tools=[],\n model=HfApiModel(\"meta-llama/Llama-3.2-3B-Instruct\"),\n)\n\n@osy.agent(\"HFCoder\", framework=\"smolagents\")\ndef hf_agent(task: str, ctx: OsyContext) -> dict:\n return SmolAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`SmolAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `agent.run(task)`. Retorna `{\"output\": str}` para texto, ou `{\"output\": \"figure\", \"_media\": [...]}` para outputs de imagem.\n\n---\n\n### OpenAI Agents SDK\n\n```bash\npip install osymandias[openai-agents]\n```\n\n```python\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n```\n\nEncapsula um `Agent` do OpenAI Agents SDK. Handoffs de agentes são emitidos como eventos `TASK_PROGRESS` para que a cadeia de handoffs seja visível no stream SSE do dashboard.\n\n```python\nfrom agents import Agent\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n\nagent = Agent(\n name=\"Assistente\",\n instructions=\"Você é um assistente prestativo.\",\n model=\"gpt-4o\",\n)\n\n@osy.agent(\"GPT4Agent\", framework=\"openai-agents\", llm_provider=\"openai\", llm_model=\"gpt-4o\")\ndef gpt4_agent(task: str, ctx: OsyContext) -> dict:\n return OpenAIAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`OpenAIAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `Runner.run_sync(agent, task)`. Emite um evento `TASK_PROGRESS` por mensagem de handoff se `ctx` for fornecido.\n\n---\n\n## Enviando jobs via API\n\n### Job em linguagem natural\n\nDeixe o PlannerAgent decompor o objetivo em tarefas automaticamente:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024 e escreva um relatório estruturado.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {}\n }'\n```\n\n**Valores de prioridade:** `\"LOW\"` · `\"NORMAL\"` · `\"HIGH\"` · `\"CRITICAL\"`\n\n### Plano de tarefas explícito (`__task_plan__`)\n\nIgnore o PlannerAgent completamente fornecendo uma lista de tarefas diretamente em `input_payload`. Útil quando você sabe exatamente quais agentes executar e em qual ordem:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquisa de mercado EV\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {\n \"__task_plan__\": [\n {\"title\": \"Pesquisa\", \"agent_type\": \"ResearchAgent\", \"description\": \"Mercado EV na Europa 2024\"},\n {\"title\": \"Relatório\", \"agent_type\": \"WriterAgent\", \"description\": \"Escreva um relatório estruturado a partir dos achados\"}\n ]\n }\n }'\n```\n\nCada entrada em `__task_plan__` aceita as mesmas chaves que os task defs do `ctx.spawn_tasks`: `title` (obrigatório), `agent_type`, `description`, `requires_approval` (ver [Controles de produção](#controles-de-produção)).\n\n### Resubmissão de um job\n\nCopia a entrada de um job concluído ou com falha e cria uma nova execução:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//resubmit\n```\n\nRetorna o novo objeto de job. O job original permanece inalterado. O botão de resubmissão também está disponível na view de detalhes do job no dashboard para qualquer job em estado terminal (COMPLETED, FAILED, CANCELLED).\n\n### Python\n\n```python\nimport httpx\n\nresp = httpx.post(\"http://localhost:47760/api/v1/jobs\", json={\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {},\n})\njob = resp.json()\nprint(job[\"id\"])\n```\n\nDocumentação interativa completa da API: **http://localhost:47760/api/v1/docs**\n\n---\n\n## Controles de produção\n\nRecursos para rodar agentes com segurança e observabilidade, sem supervisão.\n\n### Teto de tokens (budget)\n\nDefina `max_tokens` no job. Após cada chamada de LLM o runtime verifica o uso acumulado de tokens entre todos os agentes do job; ao ultrapassar o teto, o job para com status `BUDGET_EXCEEDED` e deixa de despachar novas tasks. Jobs sem teto não têm overhead algum.\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\"title\": \"Run com teto\", \"description\": \"...\", \"max_tokens\": 50000}'\n```\n\nUm evento `JOB_BUDGET_EXCEEDED` é emitido com o uso real no momento da parada.\n\n### Aprovação humana (human-in-the-loop)\n\nSegure trabalho sensível em `HUMAN_REVIEW` (emitindo `TASK_AWAITING_APPROVAL`) até um humano aprovar — garantido pelo scheduler, não só pela UI. Há dois níveis, combinados com OR:\n\n- **Por agente** — defina `requires_approval` num `AgentDefinition` (form de agente no dashboard, `PUT /api/v1/agents/{name}`, ou `@osy.agent(\"MeuAgente\", requires_approval=True)`). Toda task roteada pra esse agente gateia automaticamente. É o ponto de controle natural, já que quem cria as tasks é o planner, não você.\n- **Por task** — defina `requires_approval: true` numa task do `__task_plan__`, do output do planner, ou do `ctx.spawn_tasks([...])`. Override pontual pra DAGs explícitos.\n\nAprove para despachar:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//tasks//approve\n```\n\nListe tudo aguardando revisão entre todos os jobs (alimenta a inbox **Approvals** do dashboard):\n\n```bash\ncurl \"http://localhost:47760/api/v1/tasks?status=HUMAN_REVIEW\"\n```\n\n### Webhooks de lifecycle\n\nRegistre uma URL para receber um POST sempre que um job atingir um evento de ciclo de vida (`JOB_COMPLETED`, `JOB_FAILED`, `JOB_CANCELLED`, `JOB_BUDGET_EXCEEDED`). Omita `events` para receber todos.\n\n```bash\n# Registrar\ncurl -X POST http://localhost:47760/api/v1/webhooks \\\n -H \"Content-Type: application/json\" \\\n -d '{\"url\": \"https://example.com/hook\", \"events\": [\"JOB_COMPLETED\", \"JOB_FAILED\"]}'\n\n# Listar / remover\ncurl http://localhost:47760/api/v1/webhooks\ncurl -X DELETE http://localhost:47760/api/v1/webhooks/\n```\n\nPayload de entrega: `{\"event_type\": \"...\", \"job_id\": \"...\", \"payload\": {...}}`. A entrega é best-effort.\n\n> **Webhook de lifecycle vs `webhook_url` de tool — são coisas diferentes.**\n> Um **webhook de lifecycle** (esta página / `/api/v1/webhooks`) é *saída*: o OSymandias faz POST na sua URL **toda vez que um job termina** (completed/failed/cancelled/budget exceeded), independente do prompt. Use pra notificar Slack, disparar um fluxo n8n, atualizar um dashboard, etc.\n> O **`webhook_url`** de uma tool (no Syscall Registry / `@osy.tool`) é o oposto — *execução de entrada*: é o endpoint que o OSymandias **chama pra executar aquela tool** durante um job. Pra fazer um agente \"fazer X e mandar pro Slack\" sob demanda, registre uma tool de Slack com `webhook_url` (ex.: uma incoming webhook do Slack); o agente a chama quando a tarefa pede. Webhooks de lifecycle não são prompt-driven.\n\n### Breakdown de custo e tokens\n\nAgregação de tokens/custo por agente e por ferramenta de um job. O custo é precificado via dados do LiteLLM (com fallback estático) e acumulado por instância de agente.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//cost-breakdown\n```\n\n```json\n{\n \"by_agent\": [{\"agent\": \"ResearchAgent\", \"tokens\": 2468, \"cost\": 0.012, \"instances\": 1}],\n \"by_tool\": [{\"tool\": \"web_search\", \"calls\": 1, \"cost\": 0.0}],\n \"total_tokens\": 2468,\n \"total_cost\": 0.012\n}\n```\n\n### Trace de execução\n\nA cadeia de raciocínio completa por trás de uma task: seus eventos, tool calls e histórico de conversa registrado.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//tasks//trace\n```\n\nRetorna `{task, events, tool_calls, conversation}`.\n\n### Inspeção de memória\n\nListe entradas de memória, opcionalmente escopadas a um job ou task específico:\n\n```bash\ncurl \"http://localhost:47760/api/v1/memory?scope=JOB&scope_id=\"\n```\n\n### Cache de resposta do LLM\n\nCache determinístico opcional. Quando habilitado, chamadas idênticas (modelo + mensagens + tools + temperature) retornam a resposta do cache em vez de chamar o provider — cortando custo e latência em retries, replays e iteração de desenvolvimento. Falha de forma graciosa se o Redis estiver indisponível.\n\n```bash\n# .env\nLLM_CACHE_ENABLED=true\nLLM_CACHE_TTL_SECONDS=86400\n```\n\n---\n\n## Páginas do dashboard\n\n| Página | URL | Descrição |\n|------|-----|-------------|\n| Jobs | `/jobs` | Lista de jobs — busca, filtro por status, paginação |\n| Detalhe do job | `/jobs/{id}` | Visualizador de output, feed de eventos, timeline de tarefas, abas de custo & trace, banner de falha, approve por task, resubmissão |\n| Approvals | `/approvals` | Inbox cross-job de tasks em `HUMAN_REVIEW`, cada uma com botão approve (badge de contagem ao vivo na sidebar) |\n| Agentes | `/agents` | Registro de agentes — nativos e externos, painel de detalhes adaptativo, filtro por tipo/framework |\n| Ferramentas | `/tools` | Ferramentas nativas e funções `@osy.tool` |\n| Memória | `/memory` | Navegar e buscar entradas de memória de jobs/agentes, deletar chaves individuais |\n| Eventos | `/events` | Stream de eventos global ao vivo — pausar/retomar, filtrar por job |\n| Métricas | `/metrics` | Gráficos de 7 dias: jobs, tokens, estimativa de custo, taxa de sucesso |\n| Webhooks | `/webhooks` | Registrar/remover assinantes de webhooks de lifecycle |\n\n### Preview de output ao vivo\n\nEnquanto um job está em execução, a aba OUTPUT exibe um preview ao vivo das tarefas em andamento. Cada vez que um agente chama `ctx.emit_event(\"TASK_PROGRESS\", {...})`, o dashboard atualiza o card da tarefa em tempo real via SSE — sem polling. Quando o job conclui e `output_payload` está disponível, o output final substitui o preview automaticamente.\n\n---\n\n## Provedores de LLM suportados\n\nConfigurado durante o `osy init` ou editando o `.env` diretamente.\n\n| Provedor | Chave no `.env` | Exemplo de modelo |\n|---|---|---|\n| Ollama (local) | *(nenhuma necessária)* | `llama3.2`, `qwen2.5:7b` |\n| OpenAI | `OPENAI_API_KEY` | `gpt-4o`, `gpt-4o-mini` |\n| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |\n| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |\n| Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |\n| Gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |\n\nModelos podem ser alterados por agente no dashboard sem reiniciar o runtime.\n\n---\n\n## Escalabilidade\n\nO OSymandias escala horizontalmente executando processos Celery worker adicionais em máquinas separadas. O servidor de API, o scheduler e os workers são independentes — apenas o RabbitMQ (fila de tarefas) e o Redis (resultados + pub/sub) precisam ser acessíveis por todos os nós.\n\n### Arquitetura de filas\n\n| Fila | Responsabilidade | Recomendação de concorrência |\n|---|---|---|\n| `scheduler` | Resolução de DAG, despacho de jobs | 1 (worker único, evita race conditions) |\n| `agents` | Loops de agentes (chamadas de LLM) | Escale — maior uso de CPU/espera aqui |\n| `tools` | Chamadas `@osy.tool` e webhooks | Escale junto com workers de agentes |\n| `evaluator` | Pontuação de output | Volume baixo; 1-2 slots suficientes |\n\nA fila `scheduler` deve permanecer em uma máquina (a que executa `osy serve`). `agents`, `tools` e `evaluator` podem ser distribuídas livremente.\n\n### Concorrência local\n\nAumente slots em uma única máquina via `--concurrency` ou `.env`:\n\n```bash\n# .env\nOSY_WORKER_CONCURRENCY=8\n\n# ou na inicialização\nosy serve --concurrency 8\n```\n\n### Escalabilidade horizontal (múltiplas máquinas)\n\n```bash\n# ── Máquina A — executa API + scheduler ───────────────────────────\nosy serve --no-docker # ou com Docker\n\n# ── Máquina B — workers de agentes extras ─────────────────────────\n# Aponte para o broker/redis compartilhado no .env ou variáveis de ambiente\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues agents,tools --concurrency 8\n\n# ── Máquina C — evaluator dedicado ────────────────────────────────\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues evaluator --concurrency 2\n```\n\nOs nós worker precisam ter `osymandias` instalado e acesso ao mesmo `osymandias.toml` (ou aos mesmos módulos `@osy.agent` importáveis). Eles **não** precisam de acesso ao PostgreSQL — o servidor de API é o único processo que fala diretamente com o Postgres.\n\n### Kubernetes / deployments em container\n\nCada worker Celery é um processo stateless. Um deployment típico:\n\n| Deployment | Comando | Réplicas |\n|---|---|---|\n| API | `uvicorn osymandias.runtime.main:app` | 1-2 |\n| Workers de agentes | `celery -A osymandias.runtime.workers.celery_app worker --queues agents,tools` | N |\n| Scheduler | `celery -A osymandias.runtime.workers.celery_app worker --queues scheduler --concurrency 1` | 1 |\n| Beat | `celery -A osymandias.runtime.workers.celery_app beat` | 1 |\n\nTodos os containers worker compartilham o mesmo broker RabbitMQ e backend Redis via variáveis de ambiente.\n\n---\n\n## Dependências opcionais\n\n| Extra | Instala |\n|---|---|\n| `osymandias[langchain]` | `langchain-core`, `langchain` |\n| `osymandias[crewai]` | `crewai` |\n| `osymandias[llamaindex]` | `llama-index-core` |\n| `osymandias[smolagents]` | `smolagents` |\n| `osymandias[openai-agents]` | `openai-agents` |\n| `osymandias[all]` | Todos acima |\n\nO pacote base `osymandias` não tem dependências de framework — os extras só são necessários se você usar o adaptador correspondente.\n"}, "437a19c0be6c51ff1a9b2da9123e5d73ada748dd": {"t": 1785753117.2855887, "n": 36154, "h": "1ce15d9ddd98a04d", "c": "# OSymandias — Documentação\n\n> Referência completa da API e guia de uso.\n\n---\n\n## Índice\n\n1. [Instalação](#instalação)\n2. [Referência da CLI](#referência-da-cli)\n - [osy --version](#osy---version)\n3. [Autenticação](#autenticação)\n4. [@osy.tool — ferramentas nativas](#osytool)\n5. [@osy.agent — agentes externos](#osyagent)\n6. [osymandias.toml](#osymandiastoml)\n7. [OsyContext](#osycontext)\n8. [Adaptadores](#adaptadores)\n - [LangChain](#langchain)\n - [CrewAI](#crewai)\n - [LlamaIndex](#llamaindex)\n - [Smolagents](#smolagents)\n - [OpenAI Agents SDK](#openai-agents-sdk)\n9. [Enviando jobs via API](#enviando-jobs-via-api)\n - [Job em linguagem natural](#job-em-linguagem-natural)\n - [Plano de tarefas explícito (`__task_plan__`)](#plano-de-tarefas-explícito-__task_plan__)\n - [Resubmissão de um job](#resubmissão-de-um-job)\n10. [Controles de produção](#controles-de-produção)\n - [Teto de tokens (budget)](#teto-de-tokens-budget)\n - [Aprovação humana](#aprovação-humana-human-in-the-loop)\n - [Webhooks de lifecycle](#webhooks-de-lifecycle)\n - [Breakdown de custo e tokens](#breakdown-de-custo-e-tokens)\n - [Trace de execução](#trace-de-execução)\n - [Cache de resposta do LLM](#cache-de-resposta-do-llm)\n11. [Páginas do dashboard](#páginas-do-dashboard)\n12. [Provedores de LLM suportados](#provedores-de-llm-suportados)\n13. [Escalabilidade](#escalabilidade)\n14. [Dependências opcionais](#dependências-opcionais)\n\n---\n\n## Instalação\n\n```bash\npip install osymandias\n```\n\n**Pré-requisitos:** Python 3.11+, Docker (utilizado para gerenciar os containers de infraestrutura).\n\nExtras opcionais por framework:\n\n```bash\npip install osymandias[langchain]\npip install osymandias[crewai]\npip install osymandias[llamaindex]\npip install osymandias[smolagents]\npip install osymandias[openai-agents]\n\n# Todos de uma vez\npip install osymandias[all]\n```\n\n---\n\n## Referência da CLI\n\n### `osy --version`\n\nExibe a versão instalada e encerra.\n\n```bash\nosy --version # osy 1.1.1\nosy -V\n```\n\n---\n\n### `osy init`\n\nInicializa um novo projeto OSymandias no diretório atual.\n\n```\nosy init\n```\n\nPrompts interativos solicitam o provedor de LLM e a chave de API. Cria:\n\n| Arquivo | Finalidade |\n|---|---|\n| `OSY.compose.yml` | Docker Compose com todos os containers de infraestrutura |\n| `OSY.nginx.conf` | Config do Nginx que serve o dashboard na porta 47759 |\n| `.env` | Chaves dos provedores de LLM e configuração do runtime |\n| `osy_tools.py` | Exemplo de arquivo com `@osy.tool` para começar |\n| `osymandias.toml` | Config do projeto (agent_modules comentado) |\n\nUse `--example` para também gerar o `example_agent.py` — um par `@osy.tool` + `@osy.agent` runnable, auto-descoberto pelo `osy serve`. O comando então imprime uma dica de `osy submit` pronta pra rodar, levando você de \"instalei\" a \"job rodando\" em três comandos.\n\nSeguro para re-executar — arquivos existentes são ignorados.\n\n---\n\n### `osy serve`\n\nInicia o runtime completo.\n\n```\nosy serve [--no-docker] [--concurrency N]\n```\n\nSobe os containers Docker, executa as migrações do banco, descobre os callables `@osy.tool` e `@osy.agent`, e inicia:\n\n| Serviço | Porta |\n|---|---|\n| Dashboard (nginx) | `47759` |\n| Backend FastAPI | `47760` |\n| Servidor interno de ferramentas | `47761` |\n\n**Modo `--no-docker`** — ignora o Docker e conecta a serviços gerenciados externamente:\n\n```bash\n# No .env — descomente e atualize as URLs para suas instâncias:\n# OSY_NO_DOCKER=1\n# OSY_POSTGRES_URL=postgresql+asyncpg://user:pass@meu-db.exemplo.com:5432/osymandias\n# OSY_REDIS_URL=redis://meu-redis.exemplo.com:6379/0\n# OSY_RABBITMQ_URL=amqp://user:pass@meu-rabbit.exemplo.com:5672/\n# OSY_QDRANT_URL=http://meu-qdrant.exemplo.com:6333\n\nosy serve --no-docker\n# ou: OSY_NO_DOCKER=1 osy serve\n```\n\n`osy serve --no-docker` verifica a conectividade com todos os serviços configurados antes de iniciar e exibe um erro claro caso algum esteja inacessível. `osy stop` e `osy down` ignoram o Docker graciosamente quando não disponível.\n\n**`--concurrency N`** — número de slots simultâneos do worker Celery neste nó (padrão: 4). Também lê de `OSY_WORKER_CONCURRENCY` no `.env`.\n\n---\n\n### `osy logs`\n\nAcompanha eventos de um job específico ou do stream global.\n\n```\nosy logs [JOB_ID] [--follow] [--limit N] [--type TIPO_EVENTO]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `JOB_ID` | — | ID do job ou prefixo sem ambiguidade. Omita para todos os jobs. |\n| `--follow` / `-f` | desligado | Assina o pub/sub do Redis e transmite eventos ao vivo |\n| `--limit N` / `-n N` | `50` | Número de eventos passados a exibir antes do stream |\n| `--type` / `-t` | — | Filtra por tipo de evento (ex.: `TASK_PROGRESS`, `TOOL_CALL_STARTED`) |\n\n```bash\nosy logs # últimos 50 eventos de todos os jobs\nosy logs abc123 # últimos 50 eventos do job abc123\nosy logs abc123 -f # stream ao vivo de todos os eventos do job\nosy logs abc123 -f -t TASK_PROGRESS # stream ao vivo apenas de eventos de progresso\n```\n\n---\n\n### `osy submit`\n\nSubmete um job pelo terminal — sem curl, sem dashboard.\n\n```\nosy submit \"\" [--watch] [--max-tokens N] [--priority P] [--title T]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `` | — | O objetivo do job em linguagem natural (obrigatório) |\n| `--watch` / `-w` | off | Stream ao vivo dos eventos do job até um estado terminal |\n| `--max-tokens` | — | Teto de tokens — o job para se ultrapassar |\n| `--priority` | `NORMAL` | `HIGH` · `NORMAL` · `LOW` |\n| `--title` | (objetivo) | Título do job |\n\n```bash\nosy submit \"pesquise o mercado de EV e escreva um relatório\"\nosy submit \"resuma X\" --max-tokens 50000 --watch\n```\n\nImprime o id do novo job e um link do dashboard; com `--watch`, faz stream dos eventos até o job completar, falhar ou estourar o budget.\n\n---\n\n### `osy workers`\n\nInicia workers Celery **adicionais** para escalabilidade horizontal. Sem servidor de API, sem Docker — apenas processos worker conectando ao RabbitMQ e Redis compartilhados.\n\n```\nosy workers [--queues FILAS] [--concurrency N] [--loglevel NÍVEL]\n```\n\n| Opção | Padrão | Variável de ambiente |\n|---|---|---|\n| `--queues` | `agents,tools,evaluator` | `OSY_WORKER_QUEUES` |\n| `--concurrency` | `4` | `OSY_WORKER_CONCURRENCY` |\n| `--loglevel` | `warning` | — |\n\nExecute em qualquer máquina que consiga acessar o mesmo broker/backend. Veja [Escalabilidade](#escalabilidade).\n\n---\n\n### `osy stop`\n\nPausa todos os containers sem deletar dados.\n\n```\nosy stop\n```\n\n---\n\n### `osy down`\n\nPara e remove os containers, mas mantém os volumes (dados do PostgreSQL, vetores do Qdrant).\n\n```\nosy down\n```\n\n---\n\n### `osy delete`\n\nPara e remove containers **e** volumes. Solicita confirmação antes de deletar.\n\n```\nosy delete\n```\n\n> **Atenção:** Isso deleta permanentemente todos os jobs, memória e dados de agentes.\n\n---\n\n## Autenticação\n\nO OSymandias inclui um controle opcional por chave de API estática. A autenticação está **desabilitada por padrão** — nenhuma configuração necessária para desenvolvimento local.\n\n### Habilitando a autenticação\n\nDefina `OSY_API_KEY` no `.env` (ou como variável de ambiente):\n\n```bash\n# .env\nOSY_API_KEY=minha-chave-secreta\n```\n\nReinicie com `osy serve`. Todos os endpoints `/api/v1/*` passarão a exigir a chave. `/health`, `/docs` e `/openapi.json` estão sempre isentos.\n\n### Enviando a chave\n\nQualquer um dos headers funciona:\n\n```bash\n# Header Authorization (esquema Bearer)\ncurl -H \"Authorization: Bearer minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n\n# Header X-Api-Key\ncurl -H \"X-Api-Key: minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n```\n\nRequisições sem chave válida recebem `401 Unauthorized`.\n\n### Desabilitando a autenticação\n\nRemova `OSY_API_KEY` do `.env` (ou defina como string vazia) e reinicie.\n\n---\n\n## @osy.tool\n\nRegistra qualquer função Python como uma ferramenta de agente. As ferramentas ficam disponíveis para agentes nativos via dashboard (página `/tools`).\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef minha_ferramenta(arg1: str, arg2: int = 0) -> dict:\n \"\"\"Descrição de uma linha exibida no dashboard.\"\"\"\n return {\"resultado\": ...}\n```\n\n**Regras:**\n- O nome da função se torna o nome da ferramenta.\n- A primeira linha do docstring se torna a descrição.\n- Tipos de parâmetros e valores padrão são inferidos dos type hints.\n- O tipo de retorno deve ser `dict` (ou serializável em JSON).\n\n**Exemplo — múltiplas ferramentas:**\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef buscar_banco_de_dados(query: str, limite: int = 10) -> dict:\n \"\"\"Busca no banco de dados interno de produtos. Retorna itens correspondentes.\"\"\"\n rows = db.execute(\"SELECT * FROM products WHERE ...\", (query, limite))\n return {\"itens\": [dict(r) for r in rows]}\n\n@osy.tool\ndef enviar_mensagem_slack(canal: str, texto: str) -> dict:\n \"\"\"Envia uma mensagem para um canal do Slack.\"\"\"\n slack_client.chat_postMessage(channel=canal, text=texto)\n return {\"ok\": True}\n```\n\nAs ferramentas são descobertas automaticamente — nenhuma chamada de registro necessária. Qualquer arquivo `.py` no projeto que importe `osymandias` é escaneado ao executar `osy serve`.\n\n---\n\n## @osy.agent\n\nRegistra qualquer callable Python como um agente externo do OSymandias. O agente é despachado via Celery e aparece no dashboard `/agents` com um badge colorido.\n\n```python\nfrom osymandias import osy, OsyContext\n\n@osy.agent(\"MeuAgente\")\ndef meu_agente(task: str, ctx: OsyContext) -> dict:\n ...\n return {\"resultado\": ...}\n```\n\n### Assinatura\n\n```python\nosy.agent(\n name: str,\n *,\n description: str = \"\",\n framework: str | None = None,\n llm_provider: str | None = None,\n llm_model: str | None = None,\n output_schema = None,\n input_schema = None,\n tools: list[str] | None = None,\n) -> Callable\n```\n\n### Parâmetros\n\n| Parâmetro | Tipo | Descrição |\n|---|---|---|\n| `name` | `str` | **Obrigatório.** Nome de exibição e chave de despacho. Deve ser único. |\n| `description` | `str` | Descrição legível exibida no painel de detalhes do agente. |\n| `framework` | `str \\| None` | Framework utilizado. Controla a cor do badge no registro. Valores aceitos: `\"crewai\"`, `\"langchain\"`, `\"llamaindex\"`, `\"smolagents\"`, `\"autogen\"`. Qualquer outra string é exibida como está. |\n| `llm_provider` | `str \\| None` | Provedor utilizado internamente (ex.: `\"ollama\"`, `\"openai\"`). Apenas informativo. |\n| `llm_model` | `str \\| None` | Modelo utilizado internamente (ex.: `\"qwen2.5:7b\"`). Apenas informativo. |\n| `output_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo o formato de retorno. Exibido no painel de detalhes. |\n| `input_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo a entrada esperada além de `task: str`. |\n| `tools` | `list[str] \\| None` | Nomes das ferramentas que este agente utiliza. Apenas informativo. |\n\n> **Todos os kwargs são opcionais.** O agente executa independentemente do que for declarado. Eles existem apenas para enriquecer a exibição no dashboard.\n\n### Registro adaptativo de agentes\n\nAgentes externos são automaticamente incluídos no contexto do PlannerAgent em cada job. Quando um job em linguagem natural é submetido (sem `__task_plan__`), o planner recebe a lista completa de tipos de agentes disponíveis — nativos *e* externos — e pode rotear tarefas para qualquer um deles pelo nome. Nenhuma configuração necessária: registre um agente com `@osy.agent` e o planner o descobrirá no próximo `osy serve`.\n\n```\nosy serve ← descobre callables @osy.agent, popula o banco\n↓\nPOST /jobs {\"title\": \"...\", \"description\": \"Pesquise EVs e escreva um resumo\"}\n↓\nPlannerAgent ← vê: ResearchAgent, WriterAgent, AnalystAgent,\n MeuAgentePersonalizado [langchain] (externo), ...\n↓\nCria tarefas roteadas para os agentes corretos automaticamente\n```\n\n### Assinatura do callable\n\nA função decorada deve aceitar `task: str` como primeiro argumento e retornar um `dict`. O parâmetro `ctx: OsyContext` é opcional — inclua-o para acessar memória, eventos e sub-tarefas.\n\n```python\n# Mínimo\n@osy.agent(\"EchoAgent\")\ndef echo(task: str) -> dict:\n return {\"resultado\": task}\n\n# Com contexto\n@osy.agent(\"EchoAgent\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"ecoando\"})\n return {\"resultado\": task}\n```\n\n### Exemplo completo\n\n```python\nfrom pydantic import BaseModel\nfrom osymandias import osy, OsyContext\n\nclass ResultadoPesquisa(BaseModel):\n resumo: str\n fontes: list[str]\n confianca: float\n\n@osy.agent(\n \"ResearchAgent\",\n framework=\"langchain\",\n description=\"Pesquisa na web e resume os resultados\",\n llm_provider=\"ollama\",\n llm_model=\"qwen2.5:7b\",\n output_schema=ResultadoPesquisa,\n)\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"iniciando pesquisa\"})\n chain = build_chain()\n resultado = chain.invoke(task)\n ctx.write_memory(\"output_pesquisa\", {\"resumo\": resultado})\n return {\"resumo\": resultado, \"fontes\": [], \"confianca\": 0.9}\n```\n\n---\n\n## osymandias.toml\n\nDeclara quais módulos Python contêm seus callables `@osy.agent`. Colocado na raiz do projeto (criado automaticamente pelo `osy init`).\n\n```toml\n# osymandias.toml\n\nagent_modules = [\n \"meuprojeto.agents\",\n \"meuprojeto.crews\",\n \"meuprojeto.pipelines.research\",\n]\n```\n\nAo executar `osy serve`, cada módulo é importado em ordem e todos os decoradores `@osy.agent` nesses módulos são executados, populando o registro de agentes.\n\n**Fallback:** Se `agent_modules` estiver ausente ou o arquivo não tiver entradas, `osy serve` faz fallback para escanear todos os arquivos `.py` no diretório do projeto que importam `osymandias`. Conveniente durante o desenvolvimento, mas mais lento para projetos grandes.\n\n**Alternativa:** Você também pode declarar os módulos no `pyproject.toml`:\n\n```toml\n[tool.osymandias]\nagent_modules = [\"meuprojeto.agents\"]\n```\n\n`osymandias.toml` tem prioridade sobre `pyproject.toml` se ambos existirem.\n\n---\n\n## OsyContext\n\n`OsyContext` é injetado como parâmetro `ctx` de cada callable `@osy.agent` no momento da execução. Fornece acesso à memória compartilhada do job, ao stream de eventos ao vivo e ao spawn de sub-tarefas — tudo com escopo no job atual.\n\n```python\nfrom osymandias import OsyContext\n```\n\n---\n\n### `ctx.write_memory(key, value)`\n\nEscreve um valor na memória compartilhada do job atual.\n\n```python\nctx.write_memory(key: str, value: dict) -> None\n```\n\n- Sobrescreve qualquer valor anterior na mesma chave.\n- Todos os agentes no mesmo job compartilham o mesmo namespace — um agente LangChain pode ler o que um agente CrewAI escreveu.\n- Persistido imediatamente na transação atual do banco.\n\n```python\nctx.write_memory(\"plano\", {\n \"etapas\": [\"pesquisa\", \"análise\", \"escrita\"],\n \"prioridade\": \"alta\",\n})\n```\n\n---\n\n### `ctx.read_memory(key)`\n\nLê um valor previamente escrito da memória do job.\n\n```python\nctx.read_memory(key: str) -> dict | None\n```\n\nRetorna `None` se a chave ainda não existir.\n\n```python\nplano = ctx.read_memory(\"plano\")\nif plano:\n etapas = plano[\"etapas\"]\n```\n\n---\n\n### `ctx.search_memory(query, top_k=5)`\n\nBusca semântica vetorial sobre todas as entradas de memória do job atual.\n\n```python\nctx.search_memory(query: str, top_k: int = 5) -> list[dict]\n```\n\nUtiliza embeddings do Qdrant. Útil para recuperar outputs passados relevantes sem conhecer a chave exata.\n\n```python\nentradas = ctx.search_memory(\"preços de concorrentes\", top_k=3)\nfor e in entradas:\n print(e[\"key\"], e[\"value\"])\n```\n\n---\n\n### `ctx.emit_event(event, data)`\n\nEmite um evento que é transmitido ao vivo para o feed SSE do dashboard.\n\n```python\nctx.emit_event(event: str, data: dict) -> None\n```\n\nOs eventos aparecem instantaneamente na timeline do job. Convenções comuns (não obrigatórias):\n\n| Tipo de evento | Payload típico |\n|---|---|\n| `\"TASK_PROGRESS\"` | `{\"pct\": 50, \"step\": \"analisando\"}` |\n| `\"AGENT_LOG\"` | `{\"message\": \"encontradas 12 fontes\"}` |\n| Qualquer string customizada | Qualquer dict |\n\n```python\nctx.emit_event(\"TASK_PROGRESS\", {\"pct\": 25, \"step\": \"buscando fontes\"})\nctx.emit_event(\"AGENT_LOG\", {\"message\": f\"query retornou {n} resultados\"})\n```\n\n---\n\n### `ctx.spawn_tasks(task_defs)`\n\nCria uma ou mais sub-tarefas que rodam sob a tarefa atual.\n\n```python\nctx.spawn_tasks(task_defs: list[dict]) -> list[uuid.UUID]\n```\n\nCada sub-tarefa é enfileirada imediatamente no scheduler Celery e executa em paralelo. As tarefas aparecem como uma árvore sob a tarefa pai na timeline do job.\n\n**Chaves de task_def:**\n\n| Chave | Obrigatório | Descrição |\n|---|---|---|\n| `title` | Sim | Nome de exibição da sub-tarefa |\n| `agent_type` | Não | Nome do agente registrado para despacho (padrão: `\"ResearchAgent\"`) |\n| `description` | Não | Contexto de entrada passado ao agente |\n\nRetorna uma lista de UUIDs de tarefas na mesma ordem de `task_defs`.\n\n```python\nids = ctx.spawn_tasks([\n {\n \"title\": \"Pesquisa de Mercado\",\n \"agent_type\": \"ResearchAgent\",\n \"description\": f\"Pesquise o mercado de EVs: {task}\",\n },\n {\n \"title\": \"Análise de Concorrentes\",\n \"agent_type\": \"AnalystAgent\",\n \"description\": f\"Analise os principais concorrentes de EVs para: {task}\",\n },\n])\n```\n\n---\n\n### `ctx.wait_for_tasks(task_ids, timeout=90)`\n\nBloqueia até que todas as sub-tarefas especificadas atinjam um estado terminal (concluída, falha ou cancelada).\n\n```python\nctx.wait_for_tasks(task_ids: list[uuid.UUID], timeout: int = 90) -> dict[str, dict]\n```\n\nAssina o canal pub/sub do Redis do job e acorda imediatamente quando cada tarefa é concluída — sem intervalo de polling fixo. Faz fallback para uma leitura final no banco para cobrir o caso em que uma tarefa terminou antes da assinatura ser estabelecida. Retorna resultados para todas as tarefas independentemente de sucesso ou falha — verifique a chave `\"error\"` nos dicts individuais se necessário.\n\n**Retorna:** `{título_da_tarefa: dict_resultado}`\n\nTarefas que não concluírem dentro do `timeout` em segundos são registradas como aviso; sua entrada no dict de resultado será `{}`.\n\n```python\nids = ctx.spawn_tasks([...])\nresultados = ctx.wait_for_tasks(ids, timeout=120)\n\npesquisa = resultados.get(\"Pesquisa de Mercado\", {})\nconcorrente = resultados.get(\"Análise de Concorrentes\", {})\n\nctx.write_memory(\"combinado\", {**pesquisa, **concorrente})\nreturn {\"merged\": resultados}\n```\n\n---\n\n## Adaptadores\n\nAdaptadores encapsulam objetos de frameworks de terceiros para que seu output esteja em conformidade com o tipo de retorno `dict` esperado pelo `@osy.agent`. Cada adaptador está em `osymandias.adapters.*`.\n\n---\n\n### LangChain\n\n```bash\npip install osymandias[langchain]\n```\n\n```python\nfrom osymandias.adapters.langchain import LangChainAdapter\n```\n\nEncapsula qualquer **LCEL Runnable** do LangChain (chains, pipelines `prompt | llm | parser`) ou **AgentExecutor** legado. Automaticamente anexa um `OsyCallbackHandler` que encaminha eventos `on_llm_start`, `on_tool_start`, etc. como eventos `TASK_PROGRESS` para o dashboard.\n\n```python\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_ollama import ChatOllama\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.langchain import LangChainAdapter\n\nllm = ChatOllama(model=\"qwen2.5:7b\")\nprompt = ChatPromptTemplate.from_messages([\n (\"system\", \"Você é um assistente de pesquisa.\"),\n (\"human\", \"{input}\"),\n])\nchain = prompt | llm\n\n@osy.agent(\"ResearchAgent\", framework=\"langchain\", llm_model=\"qwen2.5:7b\")\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n return LangChainAdapter(chain).run(task, ctx=ctx)\n```\n\n**`LangChainAdapter.run(task, ctx=None) → dict`**\n\nInvoca a chain com `{\"input\": task}`. Se `ctx` for fornecido, anexa o callback handler. Normaliza o resultado para `dict` automaticamente (lida com `str`, modelos Pydantic e dicts brutos).\n\n---\n\n### CrewAI\n\n```bash\npip install osymandias[crewai]\n```\n\n```python\nfrom osymandias.adapters.crewai import CrewAIAdapter\n```\n\nEncapsula um objeto `Crew`. O crew executa como uma caixa-preta — handoffs internos entre agentes dentro do crew não são rastreados individualmente no OSymandias. Use `ctx.emit_event` manualmente dentro de callbacks do CrewAI se precisar de progresso intermediário.\n\n```python\nfrom crewai import Agent, Crew, Task\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.crewai import CrewAIAdapter\n\npesquisador = Agent(role=\"Pesquisador\", goal=\"Encontrar dados precisos\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\nanalista = Agent(role=\"Analista\", goal=\"Interpretar os achados\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\n\ncrew = Crew(\n agents=[pesquisador, analista],\n tasks=[\n Task(description=\"Pesquise {task}\", agent=pesquisador),\n Task(description=\"Analise os achados\", agent=analista),\n ],\n verbose=False,\n)\n\n@osy.agent(\"AnalystCrew\", framework=\"crewai\")\ndef analyst_crew(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"crew iniciando\"})\n resultado = CrewAIAdapter(crew).run(task, ctx=ctx)\n ctx.write_memory(\"output_crew\", resultado)\n return resultado\n```\n\n**`CrewAIAdapter.run(task, ctx=None) → dict`**\n\nChama `crew.kickoff(inputs={\"task\": task})`. Normaliza `CrewOutput.raw` para `dict`.\n\n---\n\n### LlamaIndex\n\n```bash\npip install osymandias[llamaindex]\n```\n\n```python\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n```\n\nEncapsula um `QueryEngine` (usa `.query()`) ou um `ReActAgent` (usa `.chat()`). Nós fonte da recuperação são incluídos no resultado em `\"sources\"`. Imagens encontradas nos metadados dos nós fonte são coletadas em `\"_media\"` para renderização multimodal no dashboard.\n\n```python\nfrom llama_index.core import VectorStoreIndex, SimpleDirectoryReader\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n\ndocs = SimpleDirectoryReader(\"./data\").load_data()\nindex = VectorStoreIndex.from_documents(docs)\nengine = index.as_query_engine()\n\n@osy.agent(\"RAGAgent\", framework=\"llamaindex\", description=\"Responde perguntas a partir de documentos locais\")\ndef rag_agent(task: str, ctx: OsyContext) -> dict:\n return LlamaIndexAdapter(engine).run(task, ctx=ctx)\n```\n\n**`LlamaIndexAdapter.run(task, ctx=None) → dict`**\n\nRetorna `{\"output\": str, \"sources\": list[str], \"_media\": list[dict]}`.\n\n---\n\n### Smolagents\n\n```bash\npip install osymandias[smolagents]\n```\n\n```python\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n```\n\nEncapsula qualquer agente HuggingFace Smolagents. Se o agente retornar um `matplotlib.figure.Figure` ou `PIL.Image`, é codificado em base64 e colocado em `\"_media\"` para renderização no dashboard.\n\n```python\nfrom smolagents import CodeAgent, HfApiModel\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n\nagent = CodeAgent(\n tools=[],\n model=HfApiModel(\"meta-llama/Llama-3.2-3B-Instruct\"),\n)\n\n@osy.agent(\"HFCoder\", framework=\"smolagents\")\ndef hf_agent(task: str, ctx: OsyContext) -> dict:\n return SmolAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`SmolAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `agent.run(task)`. Retorna `{\"output\": str}` para texto, ou `{\"output\": \"figure\", \"_media\": [...]}` para outputs de imagem.\n\n---\n\n### OpenAI Agents SDK\n\n```bash\npip install osymandias[openai-agents]\n```\n\n```python\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n```\n\nEncapsula um `Agent` do OpenAI Agents SDK. Handoffs de agentes são emitidos como eventos `TASK_PROGRESS` para que a cadeia de handoffs seja visível no stream SSE do dashboard.\n\n```python\nfrom agents import Agent\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n\nagent = Agent(\n name=\"Assistente\",\n instructions=\"Você é um assistente prestativo.\",\n model=\"gpt-4o\",\n)\n\n@osy.agent(\"GPT4Agent\", framework=\"openai-agents\", llm_provider=\"openai\", llm_model=\"gpt-4o\")\ndef gpt4_agent(task: str, ctx: OsyContext) -> dict:\n return OpenAIAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`OpenAIAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `Runner.run_sync(agent, task)`. Emite um evento `TASK_PROGRESS` por mensagem de handoff se `ctx` for fornecido.\n\n---\n\n## Enviando jobs via API\n\n### Job em linguagem natural\n\nDeixe o PlannerAgent decompor o objetivo em tarefas automaticamente:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024 e escreva um relatório estruturado.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {}\n }'\n```\n\n**Valores de prioridade:** `\"LOW\"` · `\"NORMAL\"` · `\"HIGH\"` · `\"CRITICAL\"`\n\n### Plano de tarefas explícito (`__task_plan__`)\n\nIgnore o PlannerAgent completamente fornecendo uma lista de tarefas diretamente em `input_payload`. Útil quando você sabe exatamente quais agentes executar e em qual ordem:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquisa de mercado EV\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {\n \"__task_plan__\": [\n {\"title\": \"Pesquisa\", \"agent_type\": \"ResearchAgent\", \"description\": \"Mercado EV na Europa 2024\"},\n {\"title\": \"Relatório\", \"agent_type\": \"WriterAgent\", \"description\": \"Escreva um relatório estruturado a partir dos achados\"}\n ]\n }\n }'\n```\n\nCada entrada em `__task_plan__` aceita as mesmas chaves que os task defs do `ctx.spawn_tasks`: `title` (obrigatório), `agent_type`, `description`, `requires_approval` (ver [Controles de produção](#controles-de-produção)).\n\n### Resubmissão de um job\n\nCopia a entrada de um job concluído ou com falha e cria uma nova execução:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//resubmit\n```\n\nRetorna o novo objeto de job. O job original permanece inalterado. O botão de resubmissão também está disponível na view de detalhes do job no dashboard para qualquer job em estado terminal (COMPLETED, FAILED, CANCELLED).\n\n### Python\n\n```python\nimport httpx\n\nresp = httpx.post(\"http://localhost:47760/api/v1/jobs\", json={\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {},\n})\njob = resp.json()\nprint(job[\"id\"])\n```\n\nDocumentação interativa completa da API: **http://localhost:47760/api/v1/docs**\n\n---\n\n## Controles de produção\n\nRecursos para rodar agentes com segurança e observabilidade, sem supervisão.\n\n### Teto de tokens (budget)\n\nDefina `max_tokens` no job. Após cada chamada de LLM o runtime verifica o uso acumulado de tokens entre todos os agentes do job; ao ultrapassar o teto, o job para com status `BUDGET_EXCEEDED` e deixa de despachar novas tasks. Jobs sem teto não têm overhead algum.\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\"title\": \"Run com teto\", \"description\": \"...\", \"max_tokens\": 50000}'\n```\n\nUm evento `JOB_BUDGET_EXCEEDED` é emitido com o uso real no momento da parada.\n\n### Aprovação humana (human-in-the-loop)\n\nSegure trabalho sensível em `HUMAN_REVIEW` (emitindo `TASK_AWAITING_APPROVAL`) até um humano aprovar — garantido pelo scheduler, não só pela UI. Há dois níveis, combinados com OR:\n\n- **Por agente** — defina `requires_approval` num `AgentDefinition` (form de agente no dashboard, `PUT /api/v1/agents/{name}`, ou `@osy.agent(\"MeuAgente\", requires_approval=True)`). Toda task roteada pra esse agente gateia automaticamente. É o ponto de controle natural, já que quem cria as tasks é o planner, não você.\n- **Por task** — defina `requires_approval: true` numa task do `__task_plan__`, do output do planner, ou do `ctx.spawn_tasks([...])`. Override pontual pra DAGs explícitos.\n\nAprove para despachar:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//tasks//approve\n```\n\nListe tudo aguardando revisão entre todos os jobs (alimenta a inbox **Approvals** do dashboard):\n\n```bash\ncurl \"http://localhost:47760/api/v1/tasks?status=HUMAN_REVIEW\"\n```\n\n### Webhooks de lifecycle\n\nRegistre uma URL para receber um POST sempre que um job atingir um evento de ciclo de vida (`JOB_COMPLETED`, `JOB_FAILED`, `JOB_CANCELLED`, `JOB_BUDGET_EXCEEDED`). Omita `events` para receber todos.\n\n```bash\n# Registrar\ncurl -X POST http://localhost:47760/api/v1/webhooks \\\n -H \"Content-Type: application/json\" \\\n -d '{\"url\": \"https://example.com/hook\", \"events\": [\"JOB_COMPLETED\", \"JOB_FAILED\"]}'\n\n# Listar / remover\ncurl http://localhost:47760/api/v1/webhooks\ncurl -X DELETE http://localhost:47760/api/v1/webhooks/\n```\n\nPayload de entrega: `{\"event_type\": \"...\", \"job_id\": \"...\", \"payload\": {...}}`. A entrega é best-effort.\n\n> **Webhook de lifecycle vs `webhook_url` de tool — são coisas diferentes.**\n> Um **webhook de lifecycle** (esta página / `/api/v1/webhooks`) é *saída*: o OSymandias faz POST na sua URL **toda vez que um job termina** (completed/failed/cancelled/budget exceeded), independente do prompt. Use pra notificar Slack, disparar um fluxo n8n, atualizar um dashboard, etc.\n> O **`webhook_url`** de uma tool (no Syscall Registry / `@osy.tool`) é o oposto — *execução de entrada*: é o endpoint que o OSymandias **chama pra executar aquela tool** durante um job. Pra fazer um agente \"fazer X e mandar pro Slack\" sob demanda, registre uma tool de Slack com `webhook_url` (ex.: uma incoming webhook do Slack); o agente a chama quando a tarefa pede. Webhooks de lifecycle não são prompt-driven.\n\n### Breakdown de custo e tokens\n\nAgregação de tokens/custo por agente e por ferramenta de um job. O custo é precificado via dados do LiteLLM (com fallback estático) e acumulado por instância de agente.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//cost-breakdown\n```\n\n```json\n{\n \"by_agent\": [{\"agent\": \"ResearchAgent\", \"tokens\": 2468, \"cost\": 0.012, \"instances\": 1}],\n \"by_tool\": [{\"tool\": \"web_search\", \"calls\": 1, \"cost\": 0.0}],\n \"total_tokens\": 2468,\n \"total_cost\": 0.012\n}\n```\n\n### Trace de execução\n\nA cadeia de raciocínio completa por trás de uma task: seus eventos, tool calls e histórico de conversa registrado.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//tasks//trace\n```\n\nRetorna `{task, events, tool_calls, conversation}`.\n\n### Inspeção de memória\n\nListe entradas de memória, opcionalmente escopadas a um job ou task específico:\n\n```bash\ncurl \"http://localhost:47760/api/v1/memory?scope=JOB&scope_id=\"\n```\n\n### Cache de resposta do LLM\n\nCache determinístico opcional. Quando habilitado, chamadas idênticas (modelo + mensagens + tools + temperature) retornam a resposta do cache em vez de chamar o provider — cortando custo e latência em retries, replays e iteração de desenvolvimento. Falha de forma graciosa se o Redis estiver indisponível.\n\n```bash\n# .env\nLLM_CACHE_ENABLED=true\nLLM_CACHE_TTL_SECONDS=86400\n```\n\n---\n\n## Páginas do dashboard\n\n| Página | URL | Descrição |\n|------|-----|-------------|\n| Jobs | `/jobs` | Lista de jobs — busca, filtro por status, paginação |\n| Detalhe do job | `/jobs/{id}` | Visualizador de output, feed de eventos, timeline de tarefas, abas de custo & trace, banner de falha, approve por task, resubmissão |\n| Approvals | `/approvals` | Inbox cross-job de tasks em `HUMAN_REVIEW`, cada uma com botão approve (badge de contagem ao vivo na sidebar) |\n| Agentes | `/agents` | Registro de agentes — nativos e externos, painel de detalhes adaptativo, filtro por tipo/framework |\n| Ferramentas | `/tools` | Ferramentas nativas e funções `@osy.tool` |\n| Memória | `/memory` | Navegar e buscar entradas de memória de jobs/agentes, deletar chaves individuais |\n| Eventos | `/events` | Stream de eventos global ao vivo — pausar/retomar, filtrar por job |\n| Métricas | `/metrics` | Gráficos de 7 dias: jobs, tokens, estimativa de custo, taxa de sucesso |\n| Webhooks | `/webhooks` | Registrar/remover assinantes de webhooks de lifecycle |\n\n### Preview de output ao vivo\n\nEnquanto um job está em execução, a aba OUTPUT exibe um preview ao vivo das tarefas em andamento. Cada vez que um agente chama `ctx.emit_event(\"TASK_PROGRESS\", {...})`, o dashboard atualiza o card da tarefa em tempo real via SSE — sem polling. Quando o job conclui e `output_payload` está disponível, o output final substitui o preview automaticamente.\n\n---\n\n## Provedores de LLM suportados\n\nConfigurado durante o `osy init` ou editando o `.env` diretamente.\n\n| Provedor | Chave no `.env` | Exemplo de modelo |\n|---|---|---|\n| Ollama (local) | *(nenhuma necessária)* | `llama3.2`, `qwen2.5:7b` |\n| OpenAI | `OPENAI_API_KEY` | `gpt-4o`, `gpt-4o-mini` |\n| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |\n| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |\n| Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |\n| Gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |\n\nModelos podem ser alterados por agente no dashboard sem reiniciar o runtime.\n\n---\n\n## Escalabilidade\n\nO OSymandias escala horizontalmente executando processos Celery worker adicionais em máquinas separadas. O servidor de API, o scheduler e os workers são independentes — apenas o RabbitMQ (fila de tarefas) e o Redis (resultados + pub/sub) precisam ser acessíveis por todos os nós.\n\n### Arquitetura de filas\n\n| Fila | Responsabilidade | Recomendação de concorrência |\n|---|---|---|\n| `scheduler` | Resolução de DAG, despacho de jobs | 1 (worker único, evita race conditions) |\n| `agents` | Loops de agentes (chamadas de LLM) | Escale — maior uso de CPU/espera aqui |\n| `tools` | Chamadas `@osy.tool` e webhooks | Escale junto com workers de agentes |\n| `evaluator` | Pontuação de output | Volume baixo; 1-2 slots suficientes |\n\nA fila `scheduler` deve permanecer em uma máquina (a que executa `osy serve`). `agents`, `tools` e `evaluator` podem ser distribuídas livremente.\n\n### Concorrência local\n\nAumente slots em uma única máquina via `--concurrency` ou `.env`:\n\n```bash\n# .env\nOSY_WORKER_CONCURRENCY=8\n\n# ou na inicialização\nosy serve --concurrency 8\n```\n\n### Escalabilidade horizontal (múltiplas máquinas)\n\n```bash\n# ── Máquina A — executa API + scheduler ───────────────────────────\nosy serve --no-docker # ou com Docker\n\n# ── Máquina B — workers de agentes extras ─────────────────────────\n# Aponte para o broker/redis compartilhado no .env ou variáveis de ambiente\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues agents,tools --concurrency 8\n\n# ── Máquina C — evaluator dedicado ────────────────────────────────\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues evaluator --concurrency 2\n```\n\nOs nós worker precisam ter `osymandias` instalado e acesso ao mesmo `osymandias.toml` (ou aos mesmos módulos `@osy.agent` importáveis). Eles **não** precisam de acesso ao PostgreSQL — o servidor de API é o único processo que fala diretamente com o Postgres.\n\n### Kubernetes / deployments em container\n\nCada worker Celery é um processo stateless. Um deployment típico:\n\n| Deployment | Comando | Réplicas |\n|---|---|---|\n| API | `uvicorn osymandias.runtime.main:app` | 1-2 |\n| Workers de agentes | `celery -A osymandias.runtime.workers.celery_app worker --queues agents,tools` | N |\n| Scheduler | `celery -A osymandias.runtime.workers.celery_app worker --queues scheduler --concurrency 1` | 1 |\n| Beat | `celery -A osymandias.runtime.workers.celery_app beat` | 1 |\n\nTodos os containers worker compartilham o mesmo broker RabbitMQ e backend Redis via variáveis de ambiente.\n\n---\n\n## Dependências opcionais\n\n| Extra | Instala |\n|---|---|\n| `osymandias[langchain]` | `langchain-core`, `langchain` |\n| `osymandias[crewai]` | `crewai` |\n| `osymandias[llamaindex]` | `llama-index-core` |\n| `osymandias[smolagents]` | `smolagents` |\n| `osymandias[openai-agents]` | `openai-agents` |\n| `osymandias[all]` | Todos acima |\n\nO pacote base `osymandias` não tem dependências de framework — os extras só são necessários se você usar o adaptador correspondente.\n"}, "ed8c53fcb68100c02377173855abbbd4e5035c04": {"t": 1785753118.668192, "n": 36154, "h": "9956c1e96e764881", "c": "# OSymandias — Documentação\n\n> Referência completa da API e guia de uso.\n\n---\n\n## Índice\n\n1. [Instalação](#instalação)\n2. [Referência da CLI](#referência-da-cli)\n - [osy --version](#osy---version)\n3. [Autenticação](#autenticação)\n4. [@osy.tool — ferramentas nativas](#osytool)\n5. [@osy.agent — agentes externos](#osyagent)\n6. [osymandias.toml](#osymandiastoml)\n7. [OsyContext](#osycontext)\n8. [Adaptadores](#adaptadores)\n - [LangChain](#langchain)\n - [CrewAI](#crewai)\n - [LlamaIndex](#llamaindex)\n - [Smolagents](#smolagents)\n - [OpenAI Agents SDK](#openai-agents-sdk)\n9. [Enviando jobs via API](#enviando-jobs-via-api)\n - [Job em linguagem natural](#job-em-linguagem-natural)\n - [Plano de tarefas explícito (`__task_plan__`)](#plano-de-tarefas-explícito-__task_plan__)\n - [Resubmissão de um job](#resubmissão-de-um-job)\n10. [Controles de produção](#controles-de-produção)\n - [Teto de tokens (budget)](#teto-de-tokens-budget)\n - [Aprovação humana](#aprovação-humana-human-in-the-loop)\n - [Webhooks de lifecycle](#webhooks-de-lifecycle)\n - [Breakdown de custo e tokens](#breakdown-de-custo-e-tokens)\n - [Trace de execução](#trace-de-execução)\n - [Cache de resposta do LLM](#cache-de-resposta-do-llm)\n11. [Páginas do dashboard](#páginas-do-dashboard)\n12. [Provedores de LLM suportados](#provedores-de-llm-suportados)\n13. [Escalabilidade](#escalabilidade)\n14. [Dependências opcionais](#dependências-opcionais)\n\n---\n\n## Instalação\n\n```bash\npip install osymandias\n```\n\n**Pré-requisitos:** Python 3.11+, Docker (utilizado para gerenciar os containers de infraestrutura).\n\nExtras opcionais por framework:\n\n```bash\npip install osymandias[langchain]\npip install osymandias[crewai]\npip install osymandias[llamaindex]\npip install osymandias[smolagents]\npip install osymandias[openai-agents]\n\n# Todos de uma vez\npip install osymandias[all]\n```\n\n---\n\n## Referência da CLI\n\n### `osy --version`\n\nExibe a versão instalada e encerra.\n\n```bash\nosy --version # osy 1.0.1\nosy -V\n```\n\n---\n\n### `osy init`\n\nInicializa um novo projeto OSymandias no diretório atual.\n\n```\nosy init\n```\n\nPrompts interativos solicitam o provedor de LLM e a chave de API. Cria:\n\n| Arquivo | Finalidade |\n|---|---|\n| `OSY.compose.yml` | Docker Compose com todos os containers de infraestrutura |\n| `OSY.nginx.conf` | Config do Nginx que serve o dashboard na porta 47759 |\n| `.env` | Chaves dos provedores de LLM e configuração do runtime |\n| `osy_tools.py` | Exemplo de arquivo com `@osy.tool` para começar |\n| `osymandias.toml` | Config do projeto (agent_modules comentado) |\n\nUse `--example` para também gerar o `example_agent.py` — um par `@osy.tool` + `@osy.agent` runnable, auto-descoberto pelo `osy serve`. O comando então imprime uma dica de `osy submit` pronta pra rodar, levando você de \"instalei\" a \"job rodando\" em três comandos.\n\nSeguro para re-executar — arquivos existentes são ignorados.\n\n---\n\n### `osy serve`\n\nInicia o runtime completo.\n\n```\nosy serve [--no-docker] [--concurrency N]\n```\n\nSobe os containers Docker, executa as migrações do banco, descobre os callables `@osy.tool` e `@osy.agent`, e inicia:\n\n| Serviço | Porta |\n|---|---|\n| Dashboard (nginx) | `47759` |\n| Backend FastAPI | `47760` |\n| Servidor interno de ferramentas | `47761` |\n\n**Modo `--no-docker`** — ignora o Docker e conecta a serviços gerenciados externamente:\n\n```bash\n# No .env — descomente e atualize as URLs para suas instâncias:\n# OSY_NO_DOCKER=1\n# OSY_POSTGRES_URL=postgresql+asyncpg://user:pass@meu-db.exemplo.com:5432/osymandias\n# OSY_REDIS_URL=redis://meu-redis.exemplo.com:6379/0\n# OSY_RABBITMQ_URL=amqp://user:pass@meu-rabbit.exemplo.com:5672/\n# OSY_QDRANT_URL=http://meu-qdrant.exemplo.com:6333\n\nosy serve --no-docker\n# ou: OSY_NO_DOCKER=1 osy serve\n```\n\n`osy serve --no-docker` verifica a conectividade com todos os serviços configurados antes de iniciar e exibe um erro claro caso algum esteja inacessível. `osy stop` e `osy down` ignoram o Docker graciosamente quando não disponível.\n\n**`--concurrency N`** — número de slots simultâneos do worker Celery neste nó (padrão: 4). Também lê de `OSY_WORKER_CONCURRENCY` no `.env`.\n\n---\n\n### `osy logs`\n\nAcompanha eventos de um job específico ou do stream global.\n\n```\nosy logs [JOB_ID] [--follow] [--limit N] [--type TIPO_EVENTO]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `JOB_ID` | — | ID do job ou prefixo sem ambiguidade. Omita para todos os jobs. |\n| `--follow` / `-f` | desligado | Assina o pub/sub do Redis e transmite eventos ao vivo |\n| `--limit N` / `-n N` | `50` | Número de eventos passados a exibir antes do stream |\n| `--type` / `-t` | — | Filtra por tipo de evento (ex.: `TASK_PROGRESS`, `TOOL_CALL_STARTED`) |\n\n```bash\nosy logs # últimos 50 eventos de todos os jobs\nosy logs abc123 # últimos 50 eventos do job abc123\nosy logs abc123 -f # stream ao vivo de todos os eventos do job\nosy logs abc123 -f -t TASK_PROGRESS # stream ao vivo apenas de eventos de progresso\n```\n\n---\n\n### `osy submit`\n\nSubmete um job pelo terminal — sem curl, sem dashboard.\n\n```\nosy submit \"\" [--watch] [--max-tokens N] [--priority P] [--title T]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `` | — | O objetivo do job em linguagem natural (obrigatório) |\n| `--watch` / `-w` | off | Stream ao vivo dos eventos do job até um estado terminal |\n| `--max-tokens` | — | Teto de tokens — o job para se ultrapassar |\n| `--priority` | `NORMAL` | `HIGH` · `NORMAL` · `LOW` |\n| `--title` | (objetivo) | Título do job |\n\n```bash\nosy submit \"pesquise o mercado de EV e escreva um relatório\"\nosy submit \"resuma X\" --max-tokens 50000 --watch\n```\n\nImprime o id do novo job e um link do dashboard; com `--watch`, faz stream dos eventos até o job completar, falhar ou estourar o budget.\n\n---\n\n### `osy workers`\n\nInicia workers Celery **adicionais** para escalabilidade horizontal. Sem servidor de API, sem Docker — apenas processos worker conectando ao RabbitMQ e Redis compartilhados.\n\n```\nosy workers [--queues FILAS] [--concurrency N] [--loglevel NÍVEL]\n```\n\n| Opção | Padrão | Variável de ambiente |\n|---|---|---|\n| `--queues` | `agents,tools,evaluator` | `OSY_WORKER_QUEUES` |\n| `--concurrency` | `4` | `OSY_WORKER_CONCURRENCY` |\n| `--loglevel` | `warning` | — |\n\nExecute em qualquer máquina que consiga acessar o mesmo broker/backend. Veja [Escalabilidade](#escalabilidade).\n\n---\n\n### `osy stop`\n\nPausa todos os containers sem deletar dados.\n\n```\nosy stop\n```\n\n---\n\n### `osy down`\n\nPara e remove os containers, mas mantém os volumes (dados do PostgreSQL, vetores do Qdrant).\n\n```\nosy down\n```\n\n---\n\n### `osy delete`\n\nPara e remove containers **e** volumes. Solicita confirmação antes de deletar.\n\n```\nosy delete\n```\n\n> **Atenção:** Isso deleta permanentemente todos os jobs, memória e dados de agentes.\n\n---\n\n## Autenticação\n\nO OSymandias inclui um controle opcional por chave de API estática. A autenticação está **desabilitada por padrão** — nenhuma configuração necessária para desenvolvimento local.\n\n### Habilitando a autenticação\n\nDefina `OSY_API_KEY` no `.env` (ou como variável de ambiente):\n\n```bash\n# .env\nOSY_API_KEY=minha-chave-secreta\n```\n\nReinicie com `osy serve`. Todos os endpoints `/api/v1/*` passarão a exigir a chave. `/health`, `/docs` e `/openapi.json` estão sempre isentos.\n\n### Enviando a chave\n\nQualquer um dos headers funciona:\n\n```bash\n# Header Authorization (esquema Bearer)\ncurl -H \"Authorization: Bearer minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n\n# Header X-Api-Key\ncurl -H \"X-Api-Key: minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n```\n\nRequisições sem chave válida recebem `401 Unauthorized`.\n\n### Desabilitando a autenticação\n\nRemova `OSY_API_KEY` do `.env` (ou defina como string vazia) e reinicie.\n\n---\n\n## @osy.tool\n\nRegistra qualquer função Python como uma ferramenta de agente. As ferramentas ficam disponíveis para agentes nativos via dashboard (página `/tools`).\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef minha_ferramenta(arg1: str, arg2: int = 0) -> dict:\n \"\"\"Descrição de uma linha exibida no dashboard.\"\"\"\n return {\"resultado\": ...}\n```\n\n**Regras:**\n- O nome da função se torna o nome da ferramenta.\n- A primeira linha do docstring se torna a descrição.\n- Tipos de parâmetros e valores padrão são inferidos dos type hints.\n- O tipo de retorno deve ser `dict` (ou serializável em JSON).\n\n**Exemplo — múltiplas ferramentas:**\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef buscar_banco_de_dados(query: str, limite: int = 10) -> dict:\n \"\"\"Busca no banco de dados interno de produtos. Retorna itens correspondentes.\"\"\"\n rows = db.execute(\"SELECT * FROM products WHERE ...\", (query, limite))\n return {\"itens\": [dict(r) for r in rows]}\n\n@osy.tool\ndef enviar_mensagem_slack(canal: str, texto: str) -> dict:\n \"\"\"Envia uma mensagem para um canal do Slack.\"\"\"\n slack_client.chat_postMessage(channel=canal, text=texto)\n return {\"ok\": True}\n```\n\nAs ferramentas são descobertas automaticamente — nenhuma chamada de registro necessária. Qualquer arquivo `.py` no projeto que importe `osymandias` é escaneado ao executar `osy serve`.\n\n---\n\n## @osy.agent\n\nRegistra qualquer callable Python como um agente externo do OSymandias. O agente é despachado via Celery e aparece no dashboard `/agents` com um badge colorido.\n\n```python\nfrom osymandias import osy, OsyContext\n\n@osy.agent(\"MeuAgente\")\ndef meu_agente(task: str, ctx: OsyContext) -> dict:\n ...\n return {\"resultado\": ...}\n```\n\n### Assinatura\n\n```python\nosy.agent(\n name: str,\n *,\n description: str = \"\",\n framework: str | None = None,\n llm_provider: str | None = None,\n llm_model: str | None = None,\n output_schema = None,\n input_schema = None,\n tools: list[str] | None = None,\n) -> Callable\n```\n\n### Parâmetros\n\n| Parâmetro | Tipo | Descrição |\n|---|---|---|\n| `name` | `str` | **Obrigatório.** Nome de exibição e chave de despacho. Deve ser único. |\n| `description` | `str` | Descrição legível exibida no painel de detalhes do agente. |\n| `framework` | `str \\| None` | Framework utilizado. Controla a cor do badge no registro. Valores aceitos: `\"crewai\"`, `\"langchain\"`, `\"llamaindex\"`, `\"smolagents\"`, `\"autogen\"`. Qualquer outra string é exibida como está. |\n| `llm_provider` | `str \\| None` | Provedor utilizado internamente (ex.: `\"ollama\"`, `\"openai\"`). Apenas informativo. |\n| `llm_model` | `str \\| None` | Modelo utilizado internamente (ex.: `\"qwen2.5:7b\"`). Apenas informativo. |\n| `output_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo o formato de retorno. Exibido no painel de detalhes. |\n| `input_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo a entrada esperada além de `task: str`. |\n| `tools` | `list[str] \\| None` | Nomes das ferramentas que este agente utiliza. Apenas informativo. |\n\n> **Todos os kwargs são opcionais.** O agente executa independentemente do que for declarado. Eles existem apenas para enriquecer a exibição no dashboard.\n\n### Registro adaptativo de agentes\n\nAgentes externos são automaticamente incluídos no contexto do PlannerAgent em cada job. Quando um job em linguagem natural é submetido (sem `__task_plan__`), o planner recebe a lista completa de tipos de agentes disponíveis — nativos *e* externos — e pode rotear tarefas para qualquer um deles pelo nome. Nenhuma configuração necessária: registre um agente com `@osy.agent` e o planner o descobrirá no próximo `osy serve`.\n\n```\nosy serve ← descobre callables @osy.agent, popula o banco\n↓\nPOST /jobs {\"title\": \"...\", \"description\": \"Pesquise EVs e escreva um resumo\"}\n↓\nPlannerAgent ← vê: ResearchAgent, WriterAgent, AnalystAgent,\n MeuAgentePersonalizado [langchain] (externo), ...\n↓\nCria tarefas roteadas para os agentes corretos automaticamente\n```\n\n### Assinatura do callable\n\nA função decorada deve aceitar `task: str` como primeiro argumento e retornar um `dict`. O parâmetro `ctx: OsyContext` é opcional — inclua-o para acessar memória, eventos e sub-tarefas.\n\n```python\n# Mínimo\n@osy.agent(\"EchoAgent\")\ndef echo(task: str) -> dict:\n return {\"resultado\": task}\n\n# Com contexto\n@osy.agent(\"EchoAgent\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"ecoando\"})\n return {\"resultado\": task}\n```\n\n### Exemplo completo\n\n```python\nfrom pydantic import BaseModel\nfrom osymandias import osy, OsyContext\n\nclass ResultadoPesquisa(BaseModel):\n resumo: str\n fontes: list[str]\n confianca: float\n\n@osy.agent(\n \"ResearchAgent\",\n framework=\"langchain\",\n description=\"Pesquisa na web e resume os resultados\",\n llm_provider=\"ollama\",\n llm_model=\"qwen2.5:7b\",\n output_schema=ResultadoPesquisa,\n)\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"iniciando pesquisa\"})\n chain = build_chain()\n resultado = chain.invoke(task)\n ctx.write_memory(\"output_pesquisa\", {\"resumo\": resultado})\n return {\"resumo\": resultado, \"fontes\": [], \"confianca\": 0.9}\n```\n\n---\n\n## osymandias.toml\n\nDeclara quais módulos Python contêm seus callables `@osy.agent`. Colocado na raiz do projeto (criado automaticamente pelo `osy init`).\n\n```toml\n# osymandias.toml\n\nagent_modules = [\n \"meuprojeto.agents\",\n \"meuprojeto.crews\",\n \"meuprojeto.pipelines.research\",\n]\n```\n\nAo executar `osy serve`, cada módulo é importado em ordem e todos os decoradores `@osy.agent` nesses módulos são executados, populando o registro de agentes.\n\n**Fallback:** Se `agent_modules` estiver ausente ou o arquivo não tiver entradas, `osy serve` faz fallback para escanear todos os arquivos `.py` no diretório do projeto que importam `osymandias`. Conveniente durante o desenvolvimento, mas mais lento para projetos grandes.\n\n**Alternativa:** Você também pode declarar os módulos no `pyproject.toml`:\n\n```toml\n[tool.osymandias]\nagent_modules = [\"meuprojeto.agents\"]\n```\n\n`osymandias.toml` tem prioridade sobre `pyproject.toml` se ambos existirem.\n\n---\n\n## OsyContext\n\n`OsyContext` é injetado como parâmetro `ctx` de cada callable `@osy.agent` no momento da execução. Fornece acesso à memória compartilhada do job, ao stream de eventos ao vivo e ao spawn de sub-tarefas — tudo com escopo no job atual.\n\n```python\nfrom osymandias import OsyContext\n```\n\n---\n\n### `ctx.write_memory(key, value)`\n\nEscreve um valor na memória compartilhada do job atual.\n\n```python\nctx.write_memory(key: str, value: dict) -> None\n```\n\n- Sobrescreve qualquer valor anterior na mesma chave.\n- Todos os agentes no mesmo job compartilham o mesmo namespace — um agente LangChain pode ler o que um agente CrewAI escreveu.\n- Persistido imediatamente na transação atual do banco.\n\n```python\nctx.write_memory(\"plano\", {\n \"etapas\": [\"pesquisa\", \"análise\", \"escrita\"],\n \"prioridade\": \"alta\",\n})\n```\n\n---\n\n### `ctx.read_memory(key)`\n\nLê um valor previamente escrito da memória do job.\n\n```python\nctx.read_memory(key: str) -> dict | None\n```\n\nRetorna `None` se a chave ainda não existir.\n\n```python\nplano = ctx.read_memory(\"plano\")\nif plano:\n etapas = plano[\"etapas\"]\n```\n\n---\n\n### `ctx.search_memory(query, top_k=5)`\n\nBusca semântica vetorial sobre todas as entradas de memória do job atual.\n\n```python\nctx.search_memory(query: str, top_k: int = 5) -> list[dict]\n```\n\nUtiliza embeddings do Qdrant. Útil para recuperar outputs passados relevantes sem conhecer a chave exata.\n\n```python\nentradas = ctx.search_memory(\"preços de concorrentes\", top_k=3)\nfor e in entradas:\n print(e[\"key\"], e[\"value\"])\n```\n\n---\n\n### `ctx.emit_event(event, data)`\n\nEmite um evento que é transmitido ao vivo para o feed SSE do dashboard.\n\n```python\nctx.emit_event(event: str, data: dict) -> None\n```\n\nOs eventos aparecem instantaneamente na timeline do job. Convenções comuns (não obrigatórias):\n\n| Tipo de evento | Payload típico |\n|---|---|\n| `\"TASK_PROGRESS\"` | `{\"pct\": 50, \"step\": \"analisando\"}` |\n| `\"AGENT_LOG\"` | `{\"message\": \"encontradas 12 fontes\"}` |\n| Qualquer string customizada | Qualquer dict |\n\n```python\nctx.emit_event(\"TASK_PROGRESS\", {\"pct\": 25, \"step\": \"buscando fontes\"})\nctx.emit_event(\"AGENT_LOG\", {\"message\": f\"query retornou {n} resultados\"})\n```\n\n---\n\n### `ctx.spawn_tasks(task_defs)`\n\nCria uma ou mais sub-tarefas que rodam sob a tarefa atual.\n\n```python\nctx.spawn_tasks(task_defs: list[dict]) -> list[uuid.UUID]\n```\n\nCada sub-tarefa é enfileirada imediatamente no scheduler Celery e executa em paralelo. As tarefas aparecem como uma árvore sob a tarefa pai na timeline do job.\n\n**Chaves de task_def:**\n\n| Chave | Obrigatório | Descrição |\n|---|---|---|\n| `title` | Sim | Nome de exibição da sub-tarefa |\n| `agent_type` | Não | Nome do agente registrado para despacho (padrão: `\"ResearchAgent\"`) |\n| `description` | Não | Contexto de entrada passado ao agente |\n\nRetorna uma lista de UUIDs de tarefas na mesma ordem de `task_defs`.\n\n```python\nids = ctx.spawn_tasks([\n {\n \"title\": \"Pesquisa de Mercado\",\n \"agent_type\": \"ResearchAgent\",\n \"description\": f\"Pesquise o mercado de EVs: {task}\",\n },\n {\n \"title\": \"Análise de Concorrentes\",\n \"agent_type\": \"AnalystAgent\",\n \"description\": f\"Analise os principais concorrentes de EVs para: {task}\",\n },\n])\n```\n\n---\n\n### `ctx.wait_for_tasks(task_ids, timeout=90)`\n\nBloqueia até que todas as sub-tarefas especificadas atinjam um estado terminal (concluída, falha ou cancelada).\n\n```python\nctx.wait_for_tasks(task_ids: list[uuid.UUID], timeout: int = 90) -> dict[str, dict]\n```\n\nAssina o canal pub/sub do Redis do job e acorda imediatamente quando cada tarefa é concluída — sem intervalo de polling fixo. Faz fallback para uma leitura final no banco para cobrir o caso em que uma tarefa terminou antes da assinatura ser estabelecida. Retorna resultados para todas as tarefas independentemente de sucesso ou falha — verifique a chave `\"error\"` nos dicts individuais se necessário.\n\n**Retorna:** `{título_da_tarefa: dict_resultado}`\n\nTarefas que não concluírem dentro do `timeout` em segundos são registradas como aviso; sua entrada no dict de resultado será `{}`.\n\n```python\nids = ctx.spawn_tasks([...])\nresultados = ctx.wait_for_tasks(ids, timeout=120)\n\npesquisa = resultados.get(\"Pesquisa de Mercado\", {})\nconcorrente = resultados.get(\"Análise de Concorrentes\", {})\n\nctx.write_memory(\"combinado\", {**pesquisa, **concorrente})\nreturn {\"merged\": resultados}\n```\n\n---\n\n## Adaptadores\n\nAdaptadores encapsulam objetos de frameworks de terceiros para que seu output esteja em conformidade com o tipo de retorno `dict` esperado pelo `@osy.agent`. Cada adaptador está em `osymandias.adapters.*`.\n\n---\n\n### LangChain\n\n```bash\npip install osymandias[langchain]\n```\n\n```python\nfrom osymandias.adapters.langchain import LangChainAdapter\n```\n\nEncapsula qualquer **LCEL Runnable** do LangChain (chains, pipelines `prompt | llm | parser`) ou **AgentExecutor** legado. Automaticamente anexa um `OsyCallbackHandler` que encaminha eventos `on_llm_start`, `on_tool_start`, etc. como eventos `TASK_PROGRESS` para o dashboard.\n\n```python\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_ollama import ChatOllama\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.langchain import LangChainAdapter\n\nllm = ChatOllama(model=\"qwen2.5:7b\")\nprompt = ChatPromptTemplate.from_messages([\n (\"system\", \"Você é um assistente de pesquisa.\"),\n (\"human\", \"{input}\"),\n])\nchain = prompt | llm\n\n@osy.agent(\"ResearchAgent\", framework=\"langchain\", llm_model=\"qwen2.5:7b\")\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n return LangChainAdapter(chain).run(task, ctx=ctx)\n```\n\n**`LangChainAdapter.run(task, ctx=None) → dict`**\n\nInvoca a chain com `{\"input\": task}`. Se `ctx` for fornecido, anexa o callback handler. Normaliza o resultado para `dict` automaticamente (lida com `str`, modelos Pydantic e dicts brutos).\n\n---\n\n### CrewAI\n\n```bash\npip install osymandias[crewai]\n```\n\n```python\nfrom osymandias.adapters.crewai import CrewAIAdapter\n```\n\nEncapsula um objeto `Crew`. O crew executa como uma caixa-preta — handoffs internos entre agentes dentro do crew não são rastreados individualmente no OSymandias. Use `ctx.emit_event` manualmente dentro de callbacks do CrewAI se precisar de progresso intermediário.\n\n```python\nfrom crewai import Agent, Crew, Task\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.crewai import CrewAIAdapter\n\npesquisador = Agent(role=\"Pesquisador\", goal=\"Encontrar dados precisos\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\nanalista = Agent(role=\"Analista\", goal=\"Interpretar os achados\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\n\ncrew = Crew(\n agents=[pesquisador, analista],\n tasks=[\n Task(description=\"Pesquise {task}\", agent=pesquisador),\n Task(description=\"Analise os achados\", agent=analista),\n ],\n verbose=False,\n)\n\n@osy.agent(\"AnalystCrew\", framework=\"crewai\")\ndef analyst_crew(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"crew iniciando\"})\n resultado = CrewAIAdapter(crew).run(task, ctx=ctx)\n ctx.write_memory(\"output_crew\", resultado)\n return resultado\n```\n\n**`CrewAIAdapter.run(task, ctx=None) → dict`**\n\nChama `crew.kickoff(inputs={\"task\": task})`. Normaliza `CrewOutput.raw` para `dict`.\n\n---\n\n### LlamaIndex\n\n```bash\npip install osymandias[llamaindex]\n```\n\n```python\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n```\n\nEncapsula um `QueryEngine` (usa `.query()`) ou um `ReActAgent` (usa `.chat()`). Nós fonte da recuperação são incluídos no resultado em `\"sources\"`. Imagens encontradas nos metadados dos nós fonte são coletadas em `\"_media\"` para renderização multimodal no dashboard.\n\n```python\nfrom llama_index.core import VectorStoreIndex, SimpleDirectoryReader\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n\ndocs = SimpleDirectoryReader(\"./data\").load_data()\nindex = VectorStoreIndex.from_documents(docs)\nengine = index.as_query_engine()\n\n@osy.agent(\"RAGAgent\", framework=\"llamaindex\", description=\"Responde perguntas a partir de documentos locais\")\ndef rag_agent(task: str, ctx: OsyContext) -> dict:\n return LlamaIndexAdapter(engine).run(task, ctx=ctx)\n```\n\n**`LlamaIndexAdapter.run(task, ctx=None) → dict`**\n\nRetorna `{\"output\": str, \"sources\": list[str], \"_media\": list[dict]}`.\n\n---\n\n### Smolagents\n\n```bash\npip install osymandias[smolagents]\n```\n\n```python\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n```\n\nEncapsula qualquer agente HuggingFace Smolagents. Se o agente retornar um `matplotlib.figure.Figure` ou `PIL.Image`, é codificado em base64 e colocado em `\"_media\"` para renderização no dashboard.\n\n```python\nfrom smolagents import CodeAgent, HfApiModel\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n\nagent = CodeAgent(\n tools=[],\n model=HfApiModel(\"meta-llama/Llama-3.2-3B-Instruct\"),\n)\n\n@osy.agent(\"HFCoder\", framework=\"smolagents\")\ndef hf_agent(task: str, ctx: OsyContext) -> dict:\n return SmolAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`SmolAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `agent.run(task)`. Retorna `{\"output\": str}` para texto, ou `{\"output\": \"figure\", \"_media\": [...]}` para outputs de imagem.\n\n---\n\n### OpenAI Agents SDK\n\n```bash\npip install osymandias[openai-agents]\n```\n\n```python\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n```\n\nEncapsula um `Agent` do OpenAI Agents SDK. Handoffs de agentes são emitidos como eventos `TASK_PROGRESS` para que a cadeia de handoffs seja visível no stream SSE do dashboard.\n\n```python\nfrom agents import Agent\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n\nagent = Agent(\n name=\"Assistente\",\n instructions=\"Você é um assistente prestativo.\",\n model=\"gpt-4o\",\n)\n\n@osy.agent(\"GPT4Agent\", framework=\"openai-agents\", llm_provider=\"openai\", llm_model=\"gpt-4o\")\ndef gpt4_agent(task: str, ctx: OsyContext) -> dict:\n return OpenAIAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`OpenAIAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `Runner.run_sync(agent, task)`. Emite um evento `TASK_PROGRESS` por mensagem de handoff se `ctx` for fornecido.\n\n---\n\n## Enviando jobs via API\n\n### Job em linguagem natural\n\nDeixe o PlannerAgent decompor o objetivo em tarefas automaticamente:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024 e escreva um relatório estruturado.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {}\n }'\n```\n\n**Valores de prioridade:** `\"LOW\"` · `\"NORMAL\"` · `\"HIGH\"` · `\"CRITICAL\"`\n\n### Plano de tarefas explícito (`__task_plan__`)\n\nIgnore o PlannerAgent completamente fornecendo uma lista de tarefas diretamente em `input_payload`. Útil quando você sabe exatamente quais agentes executar e em qual ordem:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquisa de mercado EV\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {\n \"__task_plan__\": [\n {\"title\": \"Pesquisa\", \"agent_type\": \"ResearchAgent\", \"description\": \"Mercado EV na Europa 2024\"},\n {\"title\": \"Relatório\", \"agent_type\": \"WriterAgent\", \"description\": \"Escreva um relatório estruturado a partir dos achados\"}\n ]\n }\n }'\n```\n\nCada entrada em `__task_plan__` aceita as mesmas chaves que os task defs do `ctx.spawn_tasks`: `title` (obrigatório), `agent_type`, `description`, `requires_approval` (ver [Controles de produção](#controles-de-produção)).\n\n### Resubmissão de um job\n\nCopia a entrada de um job concluído ou com falha e cria uma nova execução:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//resubmit\n```\n\nRetorna o novo objeto de job. O job original permanece inalterado. O botão de resubmissão também está disponível na view de detalhes do job no dashboard para qualquer job em estado terminal (COMPLETED, FAILED, CANCELLED).\n\n### Python\n\n```python\nimport httpx\n\nresp = httpx.post(\"http://localhost:47760/api/v1/jobs\", json={\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {},\n})\njob = resp.json()\nprint(job[\"id\"])\n```\n\nDocumentação interativa completa da API: **http://localhost:47760/api/v1/docs**\n\n---\n\n## Controles de produção\n\nRecursos para rodar agentes com segurança e observabilidade, sem supervisão.\n\n### Teto de tokens (budget)\n\nDefina `max_tokens` no job. Após cada chamada de LLM o runtime verifica o uso acumulado de tokens entre todos os agentes do job; ao ultrapassar o teto, o job para com status `BUDGET_EXCEEDED` e deixa de despachar novas tasks. Jobs sem teto não têm overhead algum.\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\"title\": \"Run com teto\", \"description\": \"...\", \"max_tokens\": 50000}'\n```\n\nUm evento `JOB_BUDGET_EXCEEDED` é emitido com o uso real no momento da parada.\n\n### Aprovação humana (human-in-the-loop)\n\nSegure trabalho sensível em `HUMAN_REVIEW` (emitindo `TASK_AWAITING_APPROVAL`) até um humano aprovar — garantido pelo scheduler, não só pela UI. Há dois níveis, combinados com OR:\n\n- **Por agente** — defina `requires_approval` num `AgentDefinition` (form de agente no dashboard, `PUT /api/v1/agents/{name}`, ou `@osy.agent(\"MeuAgente\", requires_approval=True)`). Toda task roteada pra esse agente gateia automaticamente. É o ponto de controle natural, já que quem cria as tasks é o planner, não você.\n- **Por task** — defina `requires_approval: true` numa task do `__task_plan__`, do output do planner, ou do `ctx.spawn_tasks([...])`. Override pontual pra DAGs explícitos.\n\nAprove para despachar:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//tasks//approve\n```\n\nListe tudo aguardando revisão entre todos os jobs (alimenta a inbox **Approvals** do dashboard):\n\n```bash\ncurl \"http://localhost:47760/api/v1/tasks?status=HUMAN_REVIEW\"\n```\n\n### Webhooks de lifecycle\n\nRegistre uma URL para receber um POST sempre que um job atingir um evento de ciclo de vida (`JOB_COMPLETED`, `JOB_FAILED`, `JOB_CANCELLED`, `JOB_BUDGET_EXCEEDED`). Omita `events` para receber todos.\n\n```bash\n# Registrar\ncurl -X POST http://localhost:47760/api/v1/webhooks \\\n -H \"Content-Type: application/json\" \\\n -d '{\"url\": \"https://example.com/hook\", \"events\": [\"JOB_COMPLETED\", \"JOB_FAILED\"]}'\n\n# Listar / remover\ncurl http://localhost:47760/api/v1/webhooks\ncurl -X DELETE http://localhost:47760/api/v1/webhooks/\n```\n\nPayload de entrega: `{\"event_type\": \"...\", \"job_id\": \"...\", \"payload\": {...}}`. A entrega é best-effort.\n\n> **Webhook de lifecycle vs `webhook_url` de tool — são coisas diferentes.**\n> Um **webhook de lifecycle** (esta página / `/api/v1/webhooks`) é *saída*: o OSymandias faz POST na sua URL **toda vez que um job termina** (completed/failed/cancelled/budget exceeded), independente do prompt. Use pra notificar Slack, disparar um fluxo n8n, atualizar um dashboard, etc.\n> O **`webhook_url`** de uma tool (no Syscall Registry / `@osy.tool`) é o oposto — *execução de entrada*: é o endpoint que o OSymandias **chama pra executar aquela tool** durante um job. Pra fazer um agente \"fazer X e mandar pro Slack\" sob demanda, registre uma tool de Slack com `webhook_url` (ex.: uma incoming webhook do Slack); o agente a chama quando a tarefa pede. Webhooks de lifecycle não são prompt-driven.\n\n### Breakdown de custo e tokens\n\nAgregação de tokens/custo por agente e por ferramenta de um job. O custo é precificado via dados do LiteLLM (com fallback estático) e acumulado por instância de agente.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//cost-breakdown\n```\n\n```json\n{\n \"by_agent\": [{\"agent\": \"ResearchAgent\", \"tokens\": 2468, \"cost\": 0.012, \"instances\": 1}],\n \"by_tool\": [{\"tool\": \"web_search\", \"calls\": 1, \"cost\": 0.0}],\n \"total_tokens\": 2468,\n \"total_cost\": 0.012\n}\n```\n\n### Trace de execução\n\nA cadeia de raciocínio completa por trás de uma task: seus eventos, tool calls e histórico de conversa registrado.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//tasks//trace\n```\n\nRetorna `{task, events, tool_calls, conversation}`.\n\n### Inspeção de memória\n\nListe entradas de memória, opcionalmente escopadas a um job ou task específico:\n\n```bash\ncurl \"http://localhost:47760/api/v1/memory?scope=JOB&scope_id=\"\n```\n\n### Cache de resposta do LLM\n\nCache determinístico opcional. Quando habilitado, chamadas idênticas (modelo + mensagens + tools + temperature) retornam a resposta do cache em vez de chamar o provider — cortando custo e latência em retries, replays e iteração de desenvolvimento. Falha de forma graciosa se o Redis estiver indisponível.\n\n```bash\n# .env\nLLM_CACHE_ENABLED=true\nLLM_CACHE_TTL_SECONDS=86400\n```\n\n---\n\n## Páginas do dashboard\n\n| Página | URL | Descrição |\n|------|-----|-------------|\n| Jobs | `/jobs` | Lista de jobs — busca, filtro por status, paginação |\n| Detalhe do job | `/jobs/{id}` | Visualizador de output, feed de eventos, timeline de tarefas, abas de custo & trace, banner de falha, approve por task, resubmissão |\n| Approvals | `/approvals` | Inbox cross-job de tasks em `HUMAN_REVIEW`, cada uma com botão approve (badge de contagem ao vivo na sidebar) |\n| Agentes | `/agents` | Registro de agentes — nativos e externos, painel de detalhes adaptativo, filtro por tipo/framework |\n| Ferramentas | `/tools` | Ferramentas nativas e funções `@osy.tool` |\n| Memória | `/memory` | Navegar e buscar entradas de memória de jobs/agentes, deletar chaves individuais |\n| Eventos | `/events` | Stream de eventos global ao vivo — pausar/retomar, filtrar por job |\n| Métricas | `/metrics` | Gráficos de 7 dias: jobs, tokens, estimativa de custo, taxa de sucesso |\n| Webhooks | `/webhooks` | Registrar/remover assinantes de webhooks de lifecycle |\n\n### Preview de output ao vivo\n\nEnquanto um job está em execução, a aba OUTPUT exibe um preview ao vivo das tarefas em andamento. Cada vez que um agente chama `ctx.emit_event(\"TASK_PROGRESS\", {...})`, o dashboard atualiza o card da tarefa em tempo real via SSE — sem polling. Quando o job conclui e `output_payload` está disponível, o output final substitui o preview automaticamente.\n\n---\n\n## Provedores de LLM suportados\n\nConfigurado durante o `osy init` ou editando o `.env` diretamente.\n\n| Provedor | Chave no `.env` | Exemplo de modelo |\n|---|---|---|\n| Ollama (local) | *(nenhuma necessária)* | `llama3.2`, `qwen2.5:7b` |\n| OpenAI | `OPENAI_API_KEY` | `gpt-4o`, `gpt-4o-mini` |\n| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |\n| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |\n| Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |\n| Gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |\n\nModelos podem ser alterados por agente no dashboard sem reiniciar o runtime.\n\n---\n\n## Escalabilidade\n\nO OSymandias escala horizontalmente executando processos Celery worker adicionais em máquinas separadas. O servidor de API, o scheduler e os workers são independentes — apenas o RabbitMQ (fila de tarefas) e o Redis (resultados + pub/sub) precisam ser acessíveis por todos os nós.\n\n### Arquitetura de filas\n\n| Fila | Responsabilidade | Recomendação de concorrência |\n|---|---|---|\n| `scheduler` | Resolução de DAG, despacho de jobs | 1 (worker único, evita race conditions) |\n| `agents` | Loops de agentes (chamadas de LLM) | Escale — maior uso de CPU/espera aqui |\n| `tools` | Chamadas `@osy.tool` e webhooks | Escale junto com workers de agentes |\n| `evaluator` | Pontuação de output | Volume baixo; 1-2 slots suficientes |\n\nA fila `scheduler` deve permanecer em uma máquina (a que executa `osy serve`). `agents`, `tools` e `evaluator` podem ser distribuídas livremente.\n\n### Concorrência local\n\nAumente slots em uma única máquina via `--concurrency` ou `.env`:\n\n```bash\n# .env\nOSY_WORKER_CONCURRENCY=8\n\n# ou na inicialização\nosy serve --concurrency 8\n```\n\n### Escalabilidade horizontal (múltiplas máquinas)\n\n```bash\n# ── Máquina A — executa API + scheduler ───────────────────────────\nosy serve --no-docker # ou com Docker\n\n# ── Máquina B — workers de agentes extras ─────────────────────────\n# Aponte para o broker/redis compartilhado no .env ou variáveis de ambiente\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues agents,tools --concurrency 8\n\n# ── Máquina C — evaluator dedicado ────────────────────────────────\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues evaluator --concurrency 2\n```\n\nOs nós worker precisam ter `osymandias` instalado e acesso ao mesmo `osymandias.toml` (ou aos mesmos módulos `@osy.agent` importáveis). Eles **não** precisam de acesso ao PostgreSQL — o servidor de API é o único processo que fala diretamente com o Postgres.\n\n### Kubernetes / deployments em container\n\nCada worker Celery é um processo stateless. Um deployment típico:\n\n| Deployment | Comando | Réplicas |\n|---|---|---|\n| API | `uvicorn osymandias.runtime.main:app` | 1-2 |\n| Workers de agentes | `celery -A osymandias.runtime.workers.celery_app worker --queues agents,tools` | N |\n| Scheduler | `celery -A osymandias.runtime.workers.celery_app worker --queues scheduler --concurrency 1` | 1 |\n| Beat | `celery -A osymandias.runtime.workers.celery_app beat` | 1 |\n\nTodos os containers worker compartilham o mesmo broker RabbitMQ e backend Redis via variáveis de ambiente.\n\n---\n\n## Dependências opcionais\n\n| Extra | Instala |\n|---|---|\n| `osymandias[langchain]` | `langchain-core`, `langchain` |\n| `osymandias[crewai]` | `crewai` |\n| `osymandias[llamaindex]` | `llama-index-core` |\n| `osymandias[smolagents]` | `smolagents` |\n| `osymandias[openai-agents]` | `openai-agents` |\n| `osymandias[all]` | Todos acima |\n\nO pacote base `osymandias` não tem dependências de framework — os extras só são necessários se você usar o adaptador correspondente.\n"}, "9cf6b788967276aa96ef68374682702c41d07e89": {"t": 1785753122.2624724, "n": 33444, "h": "c44c8daab56e044d", "c": "# OSymandias — Documentação\n\n> Referência completa da API e guia de uso.\n\n---\n\n## Índice\n\n1. [Instalação](#instalação)\n2. [Referência da CLI](#referência-da-cli)\n - [osy --version](#osy---version)\n3. [Autenticação](#autenticação)\n4. [@osy.tool — ferramentas nativas](#osytool)\n5. [@osy.agent — agentes externos](#osyagent)\n6. [osymandias.toml](#osymandiastoml)\n7. [OsyContext](#osycontext)\n8. [Adaptadores](#adaptadores)\n - [LangChain](#langchain)\n - [CrewAI](#crewai)\n - [LlamaIndex](#llamaindex)\n - [Smolagents](#smolagents)\n - [OpenAI Agents SDK](#openai-agents-sdk)\n9. [Enviando jobs via API](#enviando-jobs-via-api)\n - [Job em linguagem natural](#job-em-linguagem-natural)\n - [Plano de tarefas explícito (`__task_plan__`)](#plano-de-tarefas-explícito-__task_plan__)\n - [Resubmissão de um job](#resubmissão-de-um-job)\n10. [Controles de produção](#controles-de-produção)\n - [Teto de tokens (budget)](#teto-de-tokens-budget)\n - [Aprovação humana](#aprovação-humana-human-in-the-loop)\n - [Webhooks de lifecycle](#webhooks-de-lifecycle)\n - [Breakdown de custo e tokens](#breakdown-de-custo-e-tokens)\n - [Trace de execução](#trace-de-execução)\n - [Cache de resposta do LLM](#cache-de-resposta-do-llm)\n11. [Páginas do dashboard](#páginas-do-dashboard)\n12. [Provedores de LLM suportados](#provedores-de-llm-suportados)\n13. [Escalabilidade](#escalabilidade)\n14. [Dependências opcionais](#dependências-opcionais)\n\n---\n\n## Instalação\n\n```bash\npip install osymandias\n```\n\n**Pré-requisitos:** Python 3.11+, Docker (utilizado para gerenciar os containers de infraestrutura).\n\nExtras opcionais por framework:\n\n```bash\npip install osymandias[langchain]\npip install osymandias[crewai]\npip install osymandias[llamaindex]\npip install osymandias[smolagents]\npip install osymandias[openai-agents]\n\n# Todos de uma vez\npip install osymandias[all]\n```\n\n---\n\n## Referência da CLI\n\n### `osy --version`\n\nExibe a versão instalada e encerra.\n\n```bash\nosy --version # osy 1.0.1\nosy -V\n```\n\n---\n\n### `osy init`\n\nInicializa um novo projeto OSymandias no diretório atual.\n\n```\nosy init\n```\n\nPrompts interativos solicitam o provedor de LLM e a chave de API. Cria:\n\n| Arquivo | Finalidade |\n|---|---|\n| `OSY.compose.yml` | Docker Compose com todos os containers de infraestrutura |\n| `OSY.nginx.conf` | Config do Nginx que serve o dashboard na porta 47759 |\n| `.env` | Chaves dos provedores de LLM e configuração do runtime |\n| `osy_tools.py` | Exemplo de arquivo com `@osy.tool` para começar |\n| `osymandias.toml` | Config do projeto (agent_modules comentado) |\n\nSeguro para re-executar — arquivos existentes são ignorados.\n\n---\n\n### `osy serve`\n\nInicia o runtime completo.\n\n```\nosy serve [--no-docker] [--concurrency N]\n```\n\nSobe os containers Docker, executa as migrações do banco, descobre os callables `@osy.tool` e `@osy.agent`, e inicia:\n\n| Serviço | Porta |\n|---|---|\n| Dashboard (nginx) | `47759` |\n| Backend FastAPI | `47760` |\n| Servidor interno de ferramentas | `47761` |\n\n**Modo `--no-docker`** — ignora o Docker e conecta a serviços gerenciados externamente:\n\n```bash\n# No .env — descomente e atualize as URLs para suas instâncias:\n# OSY_NO_DOCKER=1\n# OSY_POSTGRES_URL=postgresql+asyncpg://user:pass@meu-db.exemplo.com:5432/osymandias\n# OSY_REDIS_URL=redis://meu-redis.exemplo.com:6379/0\n# OSY_RABBITMQ_URL=amqp://user:pass@meu-rabbit.exemplo.com:5672/\n# OSY_QDRANT_URL=http://meu-qdrant.exemplo.com:6333\n\nosy serve --no-docker\n# ou: OSY_NO_DOCKER=1 osy serve\n```\n\n`osy serve --no-docker` verifica a conectividade com todos os serviços configurados antes de iniciar e exibe um erro claro caso algum esteja inacessível. `osy stop` e `osy down` ignoram o Docker graciosamente quando não disponível.\n\n**`--concurrency N`** — número de slots simultâneos do worker Celery neste nó (padrão: 4). Também lê de `OSY_WORKER_CONCURRENCY` no `.env`.\n\n---\n\n### `osy logs`\n\nAcompanha eventos de um job específico ou do stream global.\n\n```\nosy logs [JOB_ID] [--follow] [--limit N] [--type TIPO_EVENTO]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `JOB_ID` | — | ID do job ou prefixo sem ambiguidade. Omita para todos os jobs. |\n| `--follow` / `-f` | desligado | Assina o pub/sub do Redis e transmite eventos ao vivo |\n| `--limit N` / `-n N` | `50` | Número de eventos passados a exibir antes do stream |\n| `--type` / `-t` | — | Filtra por tipo de evento (ex.: `TASK_PROGRESS`, `TOOL_CALL_STARTED`) |\n\n```bash\nosy logs # últimos 50 eventos de todos os jobs\nosy logs abc123 # últimos 50 eventos do job abc123\nosy logs abc123 -f # stream ao vivo de todos os eventos do job\nosy logs abc123 -f -t TASK_PROGRESS # stream ao vivo apenas de eventos de progresso\n```\n\n---\n\n### `osy workers`\n\nInicia workers Celery **adicionais** para escalabilidade horizontal. Sem servidor de API, sem Docker — apenas processos worker conectando ao RabbitMQ e Redis compartilhados.\n\n```\nosy workers [--queues FILAS] [--concurrency N] [--loglevel NÍVEL]\n```\n\n| Opção | Padrão | Variável de ambiente |\n|---|---|---|\n| `--queues` | `agents,tools,evaluator` | `OSY_WORKER_QUEUES` |\n| `--concurrency` | `4` | `OSY_WORKER_CONCURRENCY` |\n| `--loglevel` | `warning` | — |\n\nExecute em qualquer máquina que consiga acessar o mesmo broker/backend. Veja [Escalabilidade](#escalabilidade).\n\n---\n\n### `osy stop`\n\nPausa todos os containers sem deletar dados.\n\n```\nosy stop\n```\n\n---\n\n### `osy down`\n\nPara e remove os containers, mas mantém os volumes (dados do PostgreSQL, vetores do Qdrant).\n\n```\nosy down\n```\n\n---\n\n### `osy delete`\n\nPara e remove containers **e** volumes. Solicita confirmação antes de deletar.\n\n```\nosy delete\n```\n\n> **Atenção:** Isso deleta permanentemente todos os jobs, memória e dados de agentes.\n\n---\n\n## Autenticação\n\nO OSymandias inclui um controle opcional por chave de API estática. A autenticação está **desabilitada por padrão** — nenhuma configuração necessária para desenvolvimento local.\n\n### Habilitando a autenticação\n\nDefina `OSY_API_KEY` no `.env` (ou como variável de ambiente):\n\n```bash\n# .env\nOSY_API_KEY=minha-chave-secreta\n```\n\nReinicie com `osy serve`. Todos os endpoints `/api/v1/*` passarão a exigir a chave. `/health`, `/docs` e `/openapi.json` estão sempre isentos.\n\n### Enviando a chave\n\nQualquer um dos headers funciona:\n\n```bash\n# Header Authorization (esquema Bearer)\ncurl -H \"Authorization: Bearer minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n\n# Header X-Api-Key\ncurl -H \"X-Api-Key: minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n```\n\nRequisições sem chave válida recebem `401 Unauthorized`.\n\n### Desabilitando a autenticação\n\nRemova `OSY_API_KEY` do `.env` (ou defina como string vazia) e reinicie.\n\n---\n\n## @osy.tool\n\nRegistra qualquer função Python como uma ferramenta de agente. As ferramentas ficam disponíveis para agentes nativos via dashboard (página `/tools`).\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef minha_ferramenta(arg1: str, arg2: int = 0) -> dict:\n \"\"\"Descrição de uma linha exibida no dashboard.\"\"\"\n return {\"resultado\": ...}\n```\n\n**Regras:**\n- O nome da função se torna o nome da ferramenta.\n- A primeira linha do docstring se torna a descrição.\n- Tipos de parâmetros e valores padrão são inferidos dos type hints.\n- O tipo de retorno deve ser `dict` (ou serializável em JSON).\n\n**Exemplo — múltiplas ferramentas:**\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef buscar_banco_de_dados(query: str, limite: int = 10) -> dict:\n \"\"\"Busca no banco de dados interno de produtos. Retorna itens correspondentes.\"\"\"\n rows = db.execute(\"SELECT * FROM products WHERE ...\", (query, limite))\n return {\"itens\": [dict(r) for r in rows]}\n\n@osy.tool\ndef enviar_mensagem_slack(canal: str, texto: str) -> dict:\n \"\"\"Envia uma mensagem para um canal do Slack.\"\"\"\n slack_client.chat_postMessage(channel=canal, text=texto)\n return {\"ok\": True}\n```\n\nAs ferramentas são descobertas automaticamente — nenhuma chamada de registro necessária. Qualquer arquivo `.py` no projeto que importe `osymandias` é escaneado ao executar `osy serve`.\n\n---\n\n## @osy.agent\n\nRegistra qualquer callable Python como um agente externo do OSymandias. O agente é despachado via Celery e aparece no dashboard `/agents` com um badge colorido.\n\n```python\nfrom osymandias import osy, OsyContext\n\n@osy.agent(\"MeuAgente\")\ndef meu_agente(task: str, ctx: OsyContext) -> dict:\n ...\n return {\"resultado\": ...}\n```\n\n### Assinatura\n\n```python\nosy.agent(\n name: str,\n *,\n description: str = \"\",\n framework: str | None = None,\n llm_provider: str | None = None,\n llm_model: str | None = None,\n output_schema = None,\n input_schema = None,\n tools: list[str] | None = None,\n) -> Callable\n```\n\n### Parâmetros\n\n| Parâmetro | Tipo | Descrição |\n|---|---|---|\n| `name` | `str` | **Obrigatório.** Nome de exibição e chave de despacho. Deve ser único. |\n| `description` | `str` | Descrição legível exibida no painel de detalhes do agente. |\n| `framework` | `str \\| None` | Framework utilizado. Controla a cor do badge no registro. Valores aceitos: `\"crewai\"`, `\"langchain\"`, `\"llamaindex\"`, `\"smolagents\"`, `\"autogen\"`. Qualquer outra string é exibida como está. |\n| `llm_provider` | `str \\| None` | Provedor utilizado internamente (ex.: `\"ollama\"`, `\"openai\"`). Apenas informativo. |\n| `llm_model` | `str \\| None` | Modelo utilizado internamente (ex.: `\"qwen2.5:7b\"`). Apenas informativo. |\n| `output_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo o formato de retorno. Exibido no painel de detalhes. |\n| `input_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo a entrada esperada além de `task: str`. |\n| `tools` | `list[str] \\| None` | Nomes das ferramentas que este agente utiliza. Apenas informativo. |\n\n> **Todos os kwargs são opcionais.** O agente executa independentemente do que for declarado. Eles existem apenas para enriquecer a exibição no dashboard.\n\n### Registro adaptativo de agentes\n\nAgentes externos são automaticamente incluídos no contexto do PlannerAgent em cada job. Quando um job em linguagem natural é submetido (sem `__task_plan__`), o planner recebe a lista completa de tipos de agentes disponíveis — nativos *e* externos — e pode rotear tarefas para qualquer um deles pelo nome. Nenhuma configuração necessária: registre um agente com `@osy.agent` e o planner o descobrirá no próximo `osy serve`.\n\n```\nosy serve ← descobre callables @osy.agent, popula o banco\n↓\nPOST /jobs {\"title\": \"...\", \"description\": \"Pesquise EVs e escreva um resumo\"}\n↓\nPlannerAgent ← vê: ResearchAgent, WriterAgent, AnalystAgent,\n MeuAgentePersonalizado [langchain] (externo), ...\n↓\nCria tarefas roteadas para os agentes corretos automaticamente\n```\n\n### Assinatura do callable\n\nA função decorada deve aceitar `task: str` como primeiro argumento e retornar um `dict`. O parâmetro `ctx: OsyContext` é opcional — inclua-o para acessar memória, eventos e sub-tarefas.\n\n```python\n# Mínimo\n@osy.agent(\"EchoAgent\")\ndef echo(task: str) -> dict:\n return {\"resultado\": task}\n\n# Com contexto\n@osy.agent(\"EchoAgent\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"ecoando\"})\n return {\"resultado\": task}\n```\n\n### Exemplo completo\n\n```python\nfrom pydantic import BaseModel\nfrom osymandias import osy, OsyContext\n\nclass ResultadoPesquisa(BaseModel):\n resumo: str\n fontes: list[str]\n confianca: float\n\n@osy.agent(\n \"ResearchAgent\",\n framework=\"langchain\",\n description=\"Pesquisa na web e resume os resultados\",\n llm_provider=\"ollama\",\n llm_model=\"qwen2.5:7b\",\n output_schema=ResultadoPesquisa,\n)\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"iniciando pesquisa\"})\n chain = build_chain()\n resultado = chain.invoke(task)\n ctx.write_memory(\"output_pesquisa\", {\"resumo\": resultado})\n return {\"resumo\": resultado, \"fontes\": [], \"confianca\": 0.9}\n```\n\n---\n\n## osymandias.toml\n\nDeclara quais módulos Python contêm seus callables `@osy.agent`. Colocado na raiz do projeto (criado automaticamente pelo `osy init`).\n\n```toml\n# osymandias.toml\n\nagent_modules = [\n \"meuprojeto.agents\",\n \"meuprojeto.crews\",\n \"meuprojeto.pipelines.research\",\n]\n```\n\nAo executar `osy serve`, cada módulo é importado em ordem e todos os decoradores `@osy.agent` nesses módulos são executados, populando o registro de agentes.\n\n**Fallback:** Se `agent_modules` estiver ausente ou o arquivo não tiver entradas, `osy serve` faz fallback para escanear todos os arquivos `.py` no diretório do projeto que importam `osymandias`. Conveniente durante o desenvolvimento, mas mais lento para projetos grandes.\n\n**Alternativa:** Você também pode declarar os módulos no `pyproject.toml`:\n\n```toml\n[tool.osymandias]\nagent_modules = [\"meuprojeto.agents\"]\n```\n\n`osymandias.toml` tem prioridade sobre `pyproject.toml` se ambos existirem.\n\n---\n\n## OsyContext\n\n`OsyContext` é injetado como parâmetro `ctx` de cada callable `@osy.agent` no momento da execução. Fornece acesso à memória compartilhada do job, ao stream de eventos ao vivo e ao spawn de sub-tarefas — tudo com escopo no job atual.\n\n```python\nfrom osymandias import OsyContext\n```\n\n---\n\n### `ctx.write_memory(key, value)`\n\nEscreve um valor na memória compartilhada do job atual.\n\n```python\nctx.write_memory(key: str, value: dict) -> None\n```\n\n- Sobrescreve qualquer valor anterior na mesma chave.\n- Todos os agentes no mesmo job compartilham o mesmo namespace — um agente LangChain pode ler o que um agente CrewAI escreveu.\n- Persistido imediatamente na transação atual do banco.\n\n```python\nctx.write_memory(\"plano\", {\n \"etapas\": [\"pesquisa\", \"análise\", \"escrita\"],\n \"prioridade\": \"alta\",\n})\n```\n\n---\n\n### `ctx.read_memory(key)`\n\nLê um valor previamente escrito da memória do job.\n\n```python\nctx.read_memory(key: str) -> dict | None\n```\n\nRetorna `None` se a chave ainda não existir.\n\n```python\nplano = ctx.read_memory(\"plano\")\nif plano:\n etapas = plano[\"etapas\"]\n```\n\n---\n\n### `ctx.search_memory(query, top_k=5)`\n\nBusca semântica vetorial sobre todas as entradas de memória do job atual.\n\n```python\nctx.search_memory(query: str, top_k: int = 5) -> list[dict]\n```\n\nUtiliza embeddings do Qdrant. Útil para recuperar outputs passados relevantes sem conhecer a chave exata.\n\n```python\nentradas = ctx.search_memory(\"preços de concorrentes\", top_k=3)\nfor e in entradas:\n print(e[\"key\"], e[\"value\"])\n```\n\n---\n\n### `ctx.emit_event(event, data)`\n\nEmite um evento que é transmitido ao vivo para o feed SSE do dashboard.\n\n```python\nctx.emit_event(event: str, data: dict) -> None\n```\n\nOs eventos aparecem instantaneamente na timeline do job. Convenções comuns (não obrigatórias):\n\n| Tipo de evento | Payload típico |\n|---|---|\n| `\"TASK_PROGRESS\"` | `{\"pct\": 50, \"step\": \"analisando\"}` |\n| `\"AGENT_LOG\"` | `{\"message\": \"encontradas 12 fontes\"}` |\n| Qualquer string customizada | Qualquer dict |\n\n```python\nctx.emit_event(\"TASK_PROGRESS\", {\"pct\": 25, \"step\": \"buscando fontes\"})\nctx.emit_event(\"AGENT_LOG\", {\"message\": f\"query retornou {n} resultados\"})\n```\n\n---\n\n### `ctx.spawn_tasks(task_defs)`\n\nCria uma ou mais sub-tarefas que rodam sob a tarefa atual.\n\n```python\nctx.spawn_tasks(task_defs: list[dict]) -> list[uuid.UUID]\n```\n\nCada sub-tarefa é enfileirada imediatamente no scheduler Celery e executa em paralelo. As tarefas aparecem como uma árvore sob a tarefa pai na timeline do job.\n\n**Chaves de task_def:**\n\n| Chave | Obrigatório | Descrição |\n|---|---|---|\n| `title` | Sim | Nome de exibição da sub-tarefa |\n| `agent_type` | Não | Nome do agente registrado para despacho (padrão: `\"ResearchAgent\"`) |\n| `description` | Não | Contexto de entrada passado ao agente |\n\nRetorna uma lista de UUIDs de tarefas na mesma ordem de `task_defs`.\n\n```python\nids = ctx.spawn_tasks([\n {\n \"title\": \"Pesquisa de Mercado\",\n \"agent_type\": \"ResearchAgent\",\n \"description\": f\"Pesquise o mercado de EVs: {task}\",\n },\n {\n \"title\": \"Análise de Concorrentes\",\n \"agent_type\": \"AnalystAgent\",\n \"description\": f\"Analise os principais concorrentes de EVs para: {task}\",\n },\n])\n```\n\n---\n\n### `ctx.wait_for_tasks(task_ids, timeout=90)`\n\nBloqueia até que todas as sub-tarefas especificadas atinjam um estado terminal (concluída, falha ou cancelada).\n\n```python\nctx.wait_for_tasks(task_ids: list[uuid.UUID], timeout: int = 90) -> dict[str, dict]\n```\n\nAssina o canal pub/sub do Redis do job e acorda imediatamente quando cada tarefa é concluída — sem intervalo de polling fixo. Faz fallback para uma leitura final no banco para cobrir o caso em que uma tarefa terminou antes da assinatura ser estabelecida. Retorna resultados para todas as tarefas independentemente de sucesso ou falha — verifique a chave `\"error\"` nos dicts individuais se necessário.\n\n**Retorna:** `{título_da_tarefa: dict_resultado}`\n\nTarefas que não concluírem dentro do `timeout` em segundos são registradas como aviso; sua entrada no dict de resultado será `{}`.\n\n```python\nids = ctx.spawn_tasks([...])\nresultados = ctx.wait_for_tasks(ids, timeout=120)\n\npesquisa = resultados.get(\"Pesquisa de Mercado\", {})\nconcorrente = resultados.get(\"Análise de Concorrentes\", {})\n\nctx.write_memory(\"combinado\", {**pesquisa, **concorrente})\nreturn {\"merged\": resultados}\n```\n\n---\n\n## Adaptadores\n\nAdaptadores encapsulam objetos de frameworks de terceiros para que seu output esteja em conformidade com o tipo de retorno `dict` esperado pelo `@osy.agent`. Cada adaptador está em `osymandias.adapters.*`.\n\n---\n\n### LangChain\n\n```bash\npip install osymandias[langchain]\n```\n\n```python\nfrom osymandias.adapters.langchain import LangChainAdapter\n```\n\nEncapsula qualquer **LCEL Runnable** do LangChain (chains, pipelines `prompt | llm | parser`) ou **AgentExecutor** legado. Automaticamente anexa um `OsyCallbackHandler` que encaminha eventos `on_llm_start`, `on_tool_start`, etc. como eventos `TASK_PROGRESS` para o dashboard.\n\n```python\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_ollama import ChatOllama\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.langchain import LangChainAdapter\n\nllm = ChatOllama(model=\"qwen2.5:7b\")\nprompt = ChatPromptTemplate.from_messages([\n (\"system\", \"Você é um assistente de pesquisa.\"),\n (\"human\", \"{input}\"),\n])\nchain = prompt | llm\n\n@osy.agent(\"ResearchAgent\", framework=\"langchain\", llm_model=\"qwen2.5:7b\")\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n return LangChainAdapter(chain).run(task, ctx=ctx)\n```\n\n**`LangChainAdapter.run(task, ctx=None) → dict`**\n\nInvoca a chain com `{\"input\": task}`. Se `ctx` for fornecido, anexa o callback handler. Normaliza o resultado para `dict` automaticamente (lida com `str`, modelos Pydantic e dicts brutos).\n\n---\n\n### CrewAI\n\n```bash\npip install osymandias[crewai]\n```\n\n```python\nfrom osymandias.adapters.crewai import CrewAIAdapter\n```\n\nEncapsula um objeto `Crew`. O crew executa como uma caixa-preta — handoffs internos entre agentes dentro do crew não são rastreados individualmente no OSymandias. Use `ctx.emit_event` manualmente dentro de callbacks do CrewAI se precisar de progresso intermediário.\n\n```python\nfrom crewai import Agent, Crew, Task\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.crewai import CrewAIAdapter\n\npesquisador = Agent(role=\"Pesquisador\", goal=\"Encontrar dados precisos\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\nanalista = Agent(role=\"Analista\", goal=\"Interpretar os achados\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\n\ncrew = Crew(\n agents=[pesquisador, analista],\n tasks=[\n Task(description=\"Pesquise {task}\", agent=pesquisador),\n Task(description=\"Analise os achados\", agent=analista),\n ],\n verbose=False,\n)\n\n@osy.agent(\"AnalystCrew\", framework=\"crewai\")\ndef analyst_crew(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"crew iniciando\"})\n resultado = CrewAIAdapter(crew).run(task, ctx=ctx)\n ctx.write_memory(\"output_crew\", resultado)\n return resultado\n```\n\n**`CrewAIAdapter.run(task, ctx=None) → dict`**\n\nChama `crew.kickoff(inputs={\"task\": task})`. Normaliza `CrewOutput.raw` para `dict`.\n\n---\n\n### LlamaIndex\n\n```bash\npip install osymandias[llamaindex]\n```\n\n```python\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n```\n\nEncapsula um `QueryEngine` (usa `.query()`) ou um `ReActAgent` (usa `.chat()`). Nós fonte da recuperação são incluídos no resultado em `\"sources\"`. Imagens encontradas nos metadados dos nós fonte são coletadas em `\"_media\"` para renderização multimodal no dashboard.\n\n```python\nfrom llama_index.core import VectorStoreIndex, SimpleDirectoryReader\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n\ndocs = SimpleDirectoryReader(\"./data\").load_data()\nindex = VectorStoreIndex.from_documents(docs)\nengine = index.as_query_engine()\n\n@osy.agent(\"RAGAgent\", framework=\"llamaindex\", description=\"Responde perguntas a partir de documentos locais\")\ndef rag_agent(task: str, ctx: OsyContext) -> dict:\n return LlamaIndexAdapter(engine).run(task, ctx=ctx)\n```\n\n**`LlamaIndexAdapter.run(task, ctx=None) → dict`**\n\nRetorna `{\"output\": str, \"sources\": list[str], \"_media\": list[dict]}`.\n\n---\n\n### Smolagents\n\n```bash\npip install osymandias[smolagents]\n```\n\n```python\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n```\n\nEncapsula qualquer agente HuggingFace Smolagents. Se o agente retornar um `matplotlib.figure.Figure` ou `PIL.Image`, é codificado em base64 e colocado em `\"_media\"` para renderização no dashboard.\n\n```python\nfrom smolagents import CodeAgent, HfApiModel\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n\nagent = CodeAgent(\n tools=[],\n model=HfApiModel(\"meta-llama/Llama-3.2-3B-Instruct\"),\n)\n\n@osy.agent(\"HFCoder\", framework=\"smolagents\")\ndef hf_agent(task: str, ctx: OsyContext) -> dict:\n return SmolAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`SmolAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `agent.run(task)`. Retorna `{\"output\": str}` para texto, ou `{\"output\": \"figure\", \"_media\": [...]}` para outputs de imagem.\n\n---\n\n### OpenAI Agents SDK\n\n```bash\npip install osymandias[openai-agents]\n```\n\n```python\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n```\n\nEncapsula um `Agent` do OpenAI Agents SDK. Handoffs de agentes são emitidos como eventos `TASK_PROGRESS` para que a cadeia de handoffs seja visível no stream SSE do dashboard.\n\n```python\nfrom agents import Agent\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n\nagent = Agent(\n name=\"Assistente\",\n instructions=\"Você é um assistente prestativo.\",\n model=\"gpt-4o\",\n)\n\n@osy.agent(\"GPT4Agent\", framework=\"openai-agents\", llm_provider=\"openai\", llm_model=\"gpt-4o\")\ndef gpt4_agent(task: str, ctx: OsyContext) -> dict:\n return OpenAIAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`OpenAIAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `Runner.run_sync(agent, task)`. Emite um evento `TASK_PROGRESS` por mensagem de handoff se `ctx` for fornecido.\n\n---\n\n## Enviando jobs via API\n\n### Job em linguagem natural\n\nDeixe o PlannerAgent decompor o objetivo em tarefas automaticamente:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024 e escreva um relatório estruturado.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {}\n }'\n```\n\n**Valores de prioridade:** `\"LOW\"` · `\"NORMAL\"` · `\"HIGH\"` · `\"CRITICAL\"`\n\n### Plano de tarefas explícito (`__task_plan__`)\n\nIgnore o PlannerAgent completamente fornecendo uma lista de tarefas diretamente em `input_payload`. Útil quando você sabe exatamente quais agentes executar e em qual ordem:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquisa de mercado EV\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {\n \"__task_plan__\": [\n {\"title\": \"Pesquisa\", \"agent_type\": \"ResearchAgent\", \"description\": \"Mercado EV na Europa 2024\"},\n {\"title\": \"Relatório\", \"agent_type\": \"WriterAgent\", \"description\": \"Escreva um relatório estruturado a partir dos achados\"}\n ]\n }\n }'\n```\n\nCada entrada em `__task_plan__` aceita as mesmas chaves que os task defs do `ctx.spawn_tasks`: `title` (obrigatório), `agent_type`, `description`, `requires_approval` (ver [Controles de produção](#controles-de-produção)).\n\n### Resubmissão de um job\n\nCopia a entrada de um job concluído ou com falha e cria uma nova execução:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//resubmit\n```\n\nRetorna o novo objeto de job. O job original permanece inalterado. O botão de resubmissão também está disponível na view de detalhes do job no dashboard para qualquer job em estado terminal (COMPLETED, FAILED, CANCELLED).\n\n### Python\n\n```python\nimport httpx\n\nresp = httpx.post(\"http://localhost:47760/api/v1/jobs\", json={\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {},\n})\njob = resp.json()\nprint(job[\"id\"])\n```\n\nDocumentação interativa completa da API: **http://localhost:47760/api/v1/docs**\n\n---\n\n## Controles de produção\n\nRecursos para rodar agentes com segurança e observabilidade, sem supervisão.\n\n### Teto de tokens (budget)\n\nDefina `max_tokens` no job. Após cada chamada de LLM o runtime verifica o uso acumulado de tokens entre todos os agentes do job; ao ultrapassar o teto, o job para com status `BUDGET_EXCEEDED` e deixa de despachar novas tasks. Jobs sem teto não têm overhead algum.\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\"title\": \"Run com teto\", \"description\": \"...\", \"max_tokens\": 50000}'\n```\n\nUm evento `JOB_BUDGET_EXCEEDED` é emitido com o uso real no momento da parada.\n\n### Aprovação humana (human-in-the-loop)\n\nMarque uma task como `requires_approval: true` (no `__task_plan__` ou no task def do planner). O scheduler a segura em `HUMAN_REVIEW` (emitindo `TASK_AWAITING_APPROVAL`) em vez de despachá-la; o job permanece ativo enquanto aguarda. Aprove para despachar:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//tasks//approve\n```\n\n### Webhooks de lifecycle\n\nRegistre uma URL para receber um POST sempre que um job atingir um evento de ciclo de vida (`JOB_COMPLETED`, `JOB_FAILED`, `JOB_CANCELLED`, `JOB_BUDGET_EXCEEDED`). Omita `events` para receber todos.\n\n```bash\n# Registrar\ncurl -X POST http://localhost:47760/api/v1/webhooks \\\n -H \"Content-Type: application/json\" \\\n -d '{\"url\": \"https://example.com/hook\", \"events\": [\"JOB_COMPLETED\", \"JOB_FAILED\"]}'\n\n# Listar / remover\ncurl http://localhost:47760/api/v1/webhooks\ncurl -X DELETE http://localhost:47760/api/v1/webhooks/\n```\n\nPayload de entrega: `{\"event_type\": \"...\", \"job_id\": \"...\", \"payload\": {...}}`. A entrega é best-effort.\n\n### Breakdown de custo e tokens\n\nAgregação de tokens/custo por agente e por ferramenta de um job. O custo é precificado via dados do LiteLLM (com fallback estático) e acumulado por instância de agente.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//cost-breakdown\n```\n\n```json\n{\n \"by_agent\": [{\"agent\": \"ResearchAgent\", \"tokens\": 2468, \"cost\": 0.012, \"instances\": 1}],\n \"by_tool\": [{\"tool\": \"web_search\", \"calls\": 1, \"cost\": 0.0}],\n \"total_tokens\": 2468,\n \"total_cost\": 0.012\n}\n```\n\n### Trace de execução\n\nA cadeia de raciocínio completa por trás de uma task: seus eventos, tool calls e histórico de conversa registrado.\n\n```bash\ncurl http://localhost:47760/api/v1/jobs//tasks//trace\n```\n\nRetorna `{task, events, tool_calls, conversation}`.\n\n### Inspeção de memória\n\nListe entradas de memória, opcionalmente escopadas a um job ou task específico:\n\n```bash\ncurl \"http://localhost:47760/api/v1/memory?scope=JOB&scope_id=\"\n```\n\n### Cache de resposta do LLM\n\nCache determinístico opcional. Quando habilitado, chamadas idênticas (modelo + mensagens + tools + temperature) retornam a resposta do cache em vez de chamar o provider — cortando custo e latência em retries, replays e iteração de desenvolvimento. Falha de forma graciosa se o Redis estiver indisponível.\n\n```bash\n# .env\nLLM_CACHE_ENABLED=true\nLLM_CACHE_TTL_SECONDS=86400\n```\n\n---\n\n## Páginas do dashboard\n\n| Página | URL | Descrição |\n|------|-----|-------------|\n| Jobs | `/jobs` | Lista de jobs — busca, filtro por status, paginação |\n| Detalhe do job | `/jobs/{id}` | Visualizador de output (JSON/markdown/imagem/áudio), feed de eventos, árvore de tarefas, botão de resubmissão |\n| Agentes | `/agents` | Registro de agentes — nativos e externos, painel de detalhes adaptativo, filtro por tipo/framework |\n| Ferramentas | `/tools` | Ferramentas nativas e funções `@osy.tool` |\n| Memória | `/memory` | Navegar e buscar entradas de memória de jobs/agentes, deletar chaves individuais |\n| Eventos | `/events` | Stream de eventos global ao vivo — pausar/retomar, filtrar por job |\n| Métricas | `/metrics` | Gráficos de 7 dias: jobs, tokens, estimativa de custo, taxa de sucesso |\n\n### Preview de output ao vivo\n\nEnquanto um job está em execução, a aba OUTPUT exibe um preview ao vivo das tarefas em andamento. Cada vez que um agente chama `ctx.emit_event(\"TASK_PROGRESS\", {...})`, o dashboard atualiza o card da tarefa em tempo real via SSE — sem polling. Quando o job conclui e `output_payload` está disponível, o output final substitui o preview automaticamente.\n\n---\n\n## Provedores de LLM suportados\n\nConfigurado durante o `osy init` ou editando o `.env` diretamente.\n\n| Provedor | Chave no `.env` | Exemplo de modelo |\n|---|---|---|\n| Ollama (local) | *(nenhuma necessária)* | `llama3.2`, `qwen2.5:7b` |\n| OpenAI | `OPENAI_API_KEY` | `gpt-4o`, `gpt-4o-mini` |\n| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |\n| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |\n| Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |\n| Gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |\n\nModelos podem ser alterados por agente no dashboard sem reiniciar o runtime.\n\n---\n\n## Escalabilidade\n\nO OSymandias escala horizontalmente executando processos Celery worker adicionais em máquinas separadas. O servidor de API, o scheduler e os workers são independentes — apenas o RabbitMQ (fila de tarefas) e o Redis (resultados + pub/sub) precisam ser acessíveis por todos os nós.\n\n### Arquitetura de filas\n\n| Fila | Responsabilidade | Recomendação de concorrência |\n|---|---|---|\n| `scheduler` | Resolução de DAG, despacho de jobs | 1 (worker único, evita race conditions) |\n| `agents` | Loops de agentes (chamadas de LLM) | Escale — maior uso de CPU/espera aqui |\n| `tools` | Chamadas `@osy.tool` e webhooks | Escale junto com workers de agentes |\n| `evaluator` | Pontuação de output | Volume baixo; 1-2 slots suficientes |\n\nA fila `scheduler` deve permanecer em uma máquina (a que executa `osy serve`). `agents`, `tools` e `evaluator` podem ser distribuídas livremente.\n\n### Concorrência local\n\nAumente slots em uma única máquina via `--concurrency` ou `.env`:\n\n```bash\n# .env\nOSY_WORKER_CONCURRENCY=8\n\n# ou na inicialização\nosy serve --concurrency 8\n```\n\n### Escalabilidade horizontal (múltiplas máquinas)\n\n```bash\n# ── Máquina A — executa API + scheduler ───────────────────────────\nosy serve --no-docker # ou com Docker\n\n# ── Máquina B — workers de agentes extras ─────────────────────────\n# Aponte para o broker/redis compartilhado no .env ou variáveis de ambiente\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues agents,tools --concurrency 8\n\n# ── Máquina C — evaluator dedicado ────────────────────────────────\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues evaluator --concurrency 2\n```\n\nOs nós worker precisam ter `osymandias` instalado e acesso ao mesmo `osymandias.toml` (ou aos mesmos módulos `@osy.agent` importáveis). Eles **não** precisam de acesso ao PostgreSQL — o servidor de API é o único processo que fala diretamente com o Postgres.\n\n### Kubernetes / deployments em container\n\nCada worker Celery é um processo stateless. Um deployment típico:\n\n| Deployment | Comando | Réplicas |\n|---|---|---|\n| API | `uvicorn osymandias.runtime.main:app` | 1-2 |\n| Workers de agentes | `celery -A osymandias.runtime.workers.celery_app worker --queues agents,tools` | N |\n| Scheduler | `celery -A osymandias.runtime.workers.celery_app worker --queues scheduler --concurrency 1` | 1 |\n| Beat | `celery -A osymandias.runtime.workers.celery_app beat` | 1 |\n\nTodos os containers worker compartilham o mesmo broker RabbitMQ e backend Redis via variáveis de ambiente.\n\n---\n\n## Dependências opcionais\n\n| Extra | Instala |\n|---|---|\n| `osymandias[langchain]` | `langchain-core`, `langchain` |\n| `osymandias[crewai]` | `crewai` |\n| `osymandias[llamaindex]` | `llama-index-core` |\n| `osymandias[smolagents]` | `smolagents` |\n| `osymandias[openai-agents]` | `openai-agents` |\n| `osymandias[all]` | Todos acima |\n\nO pacote base `osymandias` não tem dependências de framework — os extras só são necessários se você usar o adaptador correspondente.\n"}, "7a61f2652aeb5632e952a90663128907a0aa6fd9": {"t": 1785753126.024634, "n": 29884, "h": "c32e714ef6a66d07", "c": "# OSymandias — Documentação\n\n> Referência completa da API e guia de uso.\n\n---\n\n## Índice\n\n1. [Instalação](#instalação)\n2. [Referência da CLI](#referência-da-cli)\n - [osy --version](#osy---version)\n3. [Autenticação](#autenticação)\n4. [@osy.tool — ferramentas nativas](#osytool)\n5. [@osy.agent — agentes externos](#osyagent)\n6. [osymandias.toml](#osymandiastoml)\n7. [OsyContext](#osycontext)\n8. [Adaptadores](#adaptadores)\n - [LangChain](#langchain)\n - [CrewAI](#crewai)\n - [LlamaIndex](#llamaindex)\n - [Smolagents](#smolagents)\n - [OpenAI Agents SDK](#openai-agents-sdk)\n9. [Enviando jobs via API](#enviando-jobs-via-api)\n - [Job em linguagem natural](#job-em-linguagem-natural)\n - [Plano de tarefas explícito (`__task_plan__`)](#plano-de-tarefas-explícito-__task_plan__)\n - [Resubmissão de um job](#resubmissão-de-um-job)\n10. [Páginas do dashboard](#páginas-do-dashboard)\n11. [Provedores de LLM suportados](#provedores-de-llm-suportados)\n12. [Escalabilidade](#escalabilidade)\n13. [Dependências opcionais](#dependências-opcionais)\n\n---\n\n## Instalação\n\n```bash\npip install osymandias\n```\n\n**Pré-requisitos:** Python 3.11+, Docker (utilizado para gerenciar os containers de infraestrutura).\n\nExtras opcionais por framework:\n\n```bash\npip install osymandias[langchain]\npip install osymandias[crewai]\npip install osymandias[llamaindex]\npip install osymandias[smolagents]\npip install osymandias[openai-agents]\n\n# Todos de uma vez\npip install osymandias[all]\n```\n\n---\n\n## Referência da CLI\n\n### `osy --version`\n\nExibe a versão instalada e encerra.\n\n```bash\nosy --version # osy 1.0.1\nosy -V\n```\n\n---\n\n### `osy init`\n\nInicializa um novo projeto OSymandias no diretório atual.\n\n```\nosy init\n```\n\nPrompts interativos solicitam o provedor de LLM e a chave de API. Cria:\n\n| Arquivo | Finalidade |\n|---|---|\n| `OSY.compose.yml` | Docker Compose com todos os containers de infraestrutura |\n| `OSY.nginx.conf` | Config do Nginx que serve o dashboard na porta 47759 |\n| `.env` | Chaves dos provedores de LLM e configuração do runtime |\n| `osy_tools.py` | Exemplo de arquivo com `@osy.tool` para começar |\n| `osymandias.toml` | Config do projeto (agent_modules comentado) |\n\nSeguro para re-executar — arquivos existentes são ignorados.\n\n---\n\n### `osy serve`\n\nInicia o runtime completo.\n\n```\nosy serve [--no-docker] [--concurrency N]\n```\n\nSobe os containers Docker, executa as migrações do banco, descobre os callables `@osy.tool` e `@osy.agent`, e inicia:\n\n| Serviço | Porta |\n|---|---|\n| Dashboard (nginx) | `47759` |\n| Backend FastAPI | `47760` |\n| Servidor interno de ferramentas | `47761` |\n\n**Modo `--no-docker`** — ignora o Docker e conecta a serviços gerenciados externamente:\n\n```bash\n# No .env — descomente e atualize as URLs para suas instâncias:\n# OSY_NO_DOCKER=1\n# OSY_POSTGRES_URL=postgresql+asyncpg://user:pass@meu-db.exemplo.com:5432/osymandias\n# OSY_REDIS_URL=redis://meu-redis.exemplo.com:6379/0\n# OSY_RABBITMQ_URL=amqp://user:pass@meu-rabbit.exemplo.com:5672/\n# OSY_QDRANT_URL=http://meu-qdrant.exemplo.com:6333\n\nosy serve --no-docker\n# ou: OSY_NO_DOCKER=1 osy serve\n```\n\n`osy serve --no-docker` verifica a conectividade com todos os serviços configurados antes de iniciar e exibe um erro claro caso algum esteja inacessível. `osy stop` e `osy down` ignoram o Docker graciosamente quando não disponível.\n\n**`--concurrency N`** — número de slots simultâneos do worker Celery neste nó (padrão: 4). Também lê de `OSY_WORKER_CONCURRENCY` no `.env`.\n\n---\n\n### `osy logs`\n\nAcompanha eventos de um job específico ou do stream global.\n\n```\nosy logs [JOB_ID] [--follow] [--limit N] [--type TIPO_EVENTO]\n```\n\n| Opção | Padrão | Descrição |\n|---|---|---|\n| `JOB_ID` | — | ID do job ou prefixo sem ambiguidade. Omita para todos os jobs. |\n| `--follow` / `-f` | desligado | Assina o pub/sub do Redis e transmite eventos ao vivo |\n| `--limit N` / `-n N` | `50` | Número de eventos passados a exibir antes do stream |\n| `--type` / `-t` | — | Filtra por tipo de evento (ex.: `TASK_PROGRESS`, `TOOL_CALL_STARTED`) |\n\n```bash\nosy logs # últimos 50 eventos de todos os jobs\nosy logs abc123 # últimos 50 eventos do job abc123\nosy logs abc123 -f # stream ao vivo de todos os eventos do job\nosy logs abc123 -f -t TASK_PROGRESS # stream ao vivo apenas de eventos de progresso\n```\n\n---\n\n### `osy workers`\n\nInicia workers Celery **adicionais** para escalabilidade horizontal. Sem servidor de API, sem Docker — apenas processos worker conectando ao RabbitMQ e Redis compartilhados.\n\n```\nosy workers [--queues FILAS] [--concurrency N] [--loglevel NÍVEL]\n```\n\n| Opção | Padrão | Variável de ambiente |\n|---|---|---|\n| `--queues` | `agents,tools,evaluator` | `OSY_WORKER_QUEUES` |\n| `--concurrency` | `4` | `OSY_WORKER_CONCURRENCY` |\n| `--loglevel` | `warning` | — |\n\nExecute em qualquer máquina que consiga acessar o mesmo broker/backend. Veja [Escalabilidade](#escalabilidade).\n\n---\n\n### `osy stop`\n\nPausa todos os containers sem deletar dados.\n\n```\nosy stop\n```\n\n---\n\n### `osy down`\n\nPara e remove os containers, mas mantém os volumes (dados do PostgreSQL, vetores do Qdrant).\n\n```\nosy down\n```\n\n---\n\n### `osy delete`\n\nPara e remove containers **e** volumes. Solicita confirmação antes de deletar.\n\n```\nosy delete\n```\n\n> **Atenção:** Isso deleta permanentemente todos os jobs, memória e dados de agentes.\n\n---\n\n## Autenticação\n\nO OSymandias inclui um controle opcional por chave de API estática. A autenticação está **desabilitada por padrão** — nenhuma configuração necessária para desenvolvimento local.\n\n### Habilitando a autenticação\n\nDefina `OSY_API_KEY` no `.env` (ou como variável de ambiente):\n\n```bash\n# .env\nOSY_API_KEY=minha-chave-secreta\n```\n\nReinicie com `osy serve`. Todos os endpoints `/api/v1/*` passarão a exigir a chave. `/health`, `/docs` e `/openapi.json` estão sempre isentos.\n\n### Enviando a chave\n\nQualquer um dos headers funciona:\n\n```bash\n# Header Authorization (esquema Bearer)\ncurl -H \"Authorization: Bearer minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n\n# Header X-Api-Key\ncurl -H \"X-Api-Key: minha-chave-secreta\" http://localhost:47760/api/v1/jobs\n```\n\nRequisições sem chave válida recebem `401 Unauthorized`.\n\n### Desabilitando a autenticação\n\nRemova `OSY_API_KEY` do `.env` (ou defina como string vazia) e reinicie.\n\n---\n\n## @osy.tool\n\nRegistra qualquer função Python como uma ferramenta de agente. As ferramentas ficam disponíveis para agentes nativos via dashboard (página `/tools`).\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef minha_ferramenta(arg1: str, arg2: int = 0) -> dict:\n \"\"\"Descrição de uma linha exibida no dashboard.\"\"\"\n return {\"resultado\": ...}\n```\n\n**Regras:**\n- O nome da função se torna o nome da ferramenta.\n- A primeira linha do docstring se torna a descrição.\n- Tipos de parâmetros e valores padrão são inferidos dos type hints.\n- O tipo de retorno deve ser `dict` (ou serializável em JSON).\n\n**Exemplo — múltiplas ferramentas:**\n\n```python\nfrom osymandias import osy\n\n@osy.tool\ndef buscar_banco_de_dados(query: str, limite: int = 10) -> dict:\n \"\"\"Busca no banco de dados interno de produtos. Retorna itens correspondentes.\"\"\"\n rows = db.execute(\"SELECT * FROM products WHERE ...\", (query, limite))\n return {\"itens\": [dict(r) for r in rows]}\n\n@osy.tool\ndef enviar_mensagem_slack(canal: str, texto: str) -> dict:\n \"\"\"Envia uma mensagem para um canal do Slack.\"\"\"\n slack_client.chat_postMessage(channel=canal, text=texto)\n return {\"ok\": True}\n```\n\nAs ferramentas são descobertas automaticamente — nenhuma chamada de registro necessária. Qualquer arquivo `.py` no projeto que importe `osymandias` é escaneado ao executar `osy serve`.\n\n---\n\n## @osy.agent\n\nRegistra qualquer callable Python como um agente externo do OSymandias. O agente é despachado via Celery e aparece no dashboard `/agents` com um badge colorido.\n\n```python\nfrom osymandias import osy, OsyContext\n\n@osy.agent(\"MeuAgente\")\ndef meu_agente(task: str, ctx: OsyContext) -> dict:\n ...\n return {\"resultado\": ...}\n```\n\n### Assinatura\n\n```python\nosy.agent(\n name: str,\n *,\n description: str = \"\",\n framework: str | None = None,\n llm_provider: str | None = None,\n llm_model: str | None = None,\n output_schema = None,\n input_schema = None,\n tools: list[str] | None = None,\n) -> Callable\n```\n\n### Parâmetros\n\n| Parâmetro | Tipo | Descrição |\n|---|---|---|\n| `name` | `str` | **Obrigatório.** Nome de exibição e chave de despacho. Deve ser único. |\n| `description` | `str` | Descrição legível exibida no painel de detalhes do agente. |\n| `framework` | `str \\| None` | Framework utilizado. Controla a cor do badge no registro. Valores aceitos: `\"crewai\"`, `\"langchain\"`, `\"llamaindex\"`, `\"smolagents\"`, `\"autogen\"`. Qualquer outra string é exibida como está. |\n| `llm_provider` | `str \\| None` | Provedor utilizado internamente (ex.: `\"ollama\"`, `\"openai\"`). Apenas informativo. |\n| `llm_model` | `str \\| None` | Modelo utilizado internamente (ex.: `\"qwen2.5:7b\"`). Apenas informativo. |\n| `output_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo o formato de retorno. Exibido no painel de detalhes. |\n| `input_schema` | Modelo Pydantic ou `dict` | JSON Schema descrevendo a entrada esperada além de `task: str`. |\n| `tools` | `list[str] \\| None` | Nomes das ferramentas que este agente utiliza. Apenas informativo. |\n\n> **Todos os kwargs são opcionais.** O agente executa independentemente do que for declarado. Eles existem apenas para enriquecer a exibição no dashboard.\n\n### Registro adaptativo de agentes\n\nAgentes externos são automaticamente incluídos no contexto do PlannerAgent em cada job. Quando um job em linguagem natural é submetido (sem `__task_plan__`), o planner recebe a lista completa de tipos de agentes disponíveis — nativos *e* externos — e pode rotear tarefas para qualquer um deles pelo nome. Nenhuma configuração necessária: registre um agente com `@osy.agent` e o planner o descobrirá no próximo `osy serve`.\n\n```\nosy serve ← descobre callables @osy.agent, popula o banco\n↓\nPOST /jobs {\"title\": \"...\", \"description\": \"Pesquise EVs e escreva um resumo\"}\n↓\nPlannerAgent ← vê: ResearchAgent, WriterAgent, AnalystAgent,\n MeuAgentePersonalizado [langchain] (externo), ...\n↓\nCria tarefas roteadas para os agentes corretos automaticamente\n```\n\n### Assinatura do callable\n\nA função decorada deve aceitar `task: str` como primeiro argumento e retornar um `dict`. O parâmetro `ctx: OsyContext` é opcional — inclua-o para acessar memória, eventos e sub-tarefas.\n\n```python\n# Mínimo\n@osy.agent(\"EchoAgent\")\ndef echo(task: str) -> dict:\n return {\"resultado\": task}\n\n# Com contexto\n@osy.agent(\"EchoAgent\")\ndef echo(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"ecoando\"})\n return {\"resultado\": task}\n```\n\n### Exemplo completo\n\n```python\nfrom pydantic import BaseModel\nfrom osymandias import osy, OsyContext\n\nclass ResultadoPesquisa(BaseModel):\n resumo: str\n fontes: list[str]\n confianca: float\n\n@osy.agent(\n \"ResearchAgent\",\n framework=\"langchain\",\n description=\"Pesquisa na web e resume os resultados\",\n llm_provider=\"ollama\",\n llm_model=\"qwen2.5:7b\",\n output_schema=ResultadoPesquisa,\n)\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"iniciando pesquisa\"})\n chain = build_chain()\n resultado = chain.invoke(task)\n ctx.write_memory(\"output_pesquisa\", {\"resumo\": resultado})\n return {\"resumo\": resultado, \"fontes\": [], \"confianca\": 0.9}\n```\n\n---\n\n## osymandias.toml\n\nDeclara quais módulos Python contêm seus callables `@osy.agent`. Colocado na raiz do projeto (criado automaticamente pelo `osy init`).\n\n```toml\n# osymandias.toml\n\nagent_modules = [\n \"meuprojeto.agents\",\n \"meuprojeto.crews\",\n \"meuprojeto.pipelines.research\",\n]\n```\n\nAo executar `osy serve`, cada módulo é importado em ordem e todos os decoradores `@osy.agent` nesses módulos são executados, populando o registro de agentes.\n\n**Fallback:** Se `agent_modules` estiver ausente ou o arquivo não tiver entradas, `osy serve` faz fallback para escanear todos os arquivos `.py` no diretório do projeto que importam `osymandias`. Conveniente durante o desenvolvimento, mas mais lento para projetos grandes.\n\n**Alternativa:** Você também pode declarar os módulos no `pyproject.toml`:\n\n```toml\n[tool.osymandias]\nagent_modules = [\"meuprojeto.agents\"]\n```\n\n`osymandias.toml` tem prioridade sobre `pyproject.toml` se ambos existirem.\n\n---\n\n## OsyContext\n\n`OsyContext` é injetado como parâmetro `ctx` de cada callable `@osy.agent` no momento da execução. Fornece acesso à memória compartilhada do job, ao stream de eventos ao vivo e ao spawn de sub-tarefas — tudo com escopo no job atual.\n\n```python\nfrom osymandias import OsyContext\n```\n\n---\n\n### `ctx.write_memory(key, value)`\n\nEscreve um valor na memória compartilhada do job atual.\n\n```python\nctx.write_memory(key: str, value: dict) -> None\n```\n\n- Sobrescreve qualquer valor anterior na mesma chave.\n- Todos os agentes no mesmo job compartilham o mesmo namespace — um agente LangChain pode ler o que um agente CrewAI escreveu.\n- Persistido imediatamente na transação atual do banco.\n\n```python\nctx.write_memory(\"plano\", {\n \"etapas\": [\"pesquisa\", \"análise\", \"escrita\"],\n \"prioridade\": \"alta\",\n})\n```\n\n---\n\n### `ctx.read_memory(key)`\n\nLê um valor previamente escrito da memória do job.\n\n```python\nctx.read_memory(key: str) -> dict | None\n```\n\nRetorna `None` se a chave ainda não existir.\n\n```python\nplano = ctx.read_memory(\"plano\")\nif plano:\n etapas = plano[\"etapas\"]\n```\n\n---\n\n### `ctx.search_memory(query, top_k=5)`\n\nBusca semântica vetorial sobre todas as entradas de memória do job atual.\n\n```python\nctx.search_memory(query: str, top_k: int = 5) -> list[dict]\n```\n\nUtiliza embeddings do Qdrant. Útil para recuperar outputs passados relevantes sem conhecer a chave exata.\n\n```python\nentradas = ctx.search_memory(\"preços de concorrentes\", top_k=3)\nfor e in entradas:\n print(e[\"key\"], e[\"value\"])\n```\n\n---\n\n### `ctx.emit_event(event, data)`\n\nEmite um evento que é transmitido ao vivo para o feed SSE do dashboard.\n\n```python\nctx.emit_event(event: str, data: dict) -> None\n```\n\nOs eventos aparecem instantaneamente na timeline do job. Convenções comuns (não obrigatórias):\n\n| Tipo de evento | Payload típico |\n|---|---|\n| `\"TASK_PROGRESS\"` | `{\"pct\": 50, \"step\": \"analisando\"}` |\n| `\"AGENT_LOG\"` | `{\"message\": \"encontradas 12 fontes\"}` |\n| Qualquer string customizada | Qualquer dict |\n\n```python\nctx.emit_event(\"TASK_PROGRESS\", {\"pct\": 25, \"step\": \"buscando fontes\"})\nctx.emit_event(\"AGENT_LOG\", {\"message\": f\"query retornou {n} resultados\"})\n```\n\n---\n\n### `ctx.spawn_tasks(task_defs)`\n\nCria uma ou mais sub-tarefas que rodam sob a tarefa atual.\n\n```python\nctx.spawn_tasks(task_defs: list[dict]) -> list[uuid.UUID]\n```\n\nCada sub-tarefa é enfileirada imediatamente no scheduler Celery e executa em paralelo. As tarefas aparecem como uma árvore sob a tarefa pai na timeline do job.\n\n**Chaves de task_def:**\n\n| Chave | Obrigatório | Descrição |\n|---|---|---|\n| `title` | Sim | Nome de exibição da sub-tarefa |\n| `agent_type` | Não | Nome do agente registrado para despacho (padrão: `\"ResearchAgent\"`) |\n| `description` | Não | Contexto de entrada passado ao agente |\n\nRetorna uma lista de UUIDs de tarefas na mesma ordem de `task_defs`.\n\n```python\nids = ctx.spawn_tasks([\n {\n \"title\": \"Pesquisa de Mercado\",\n \"agent_type\": \"ResearchAgent\",\n \"description\": f\"Pesquise o mercado de EVs: {task}\",\n },\n {\n \"title\": \"Análise de Concorrentes\",\n \"agent_type\": \"AnalystAgent\",\n \"description\": f\"Analise os principais concorrentes de EVs para: {task}\",\n },\n])\n```\n\n---\n\n### `ctx.wait_for_tasks(task_ids, timeout=90)`\n\nBloqueia até que todas as sub-tarefas especificadas atinjam um estado terminal (concluída, falha ou cancelada).\n\n```python\nctx.wait_for_tasks(task_ids: list[uuid.UUID], timeout: int = 90) -> dict[str, dict]\n```\n\nAssina o canal pub/sub do Redis do job e acorda imediatamente quando cada tarefa é concluída — sem intervalo de polling fixo. Faz fallback para uma leitura final no banco para cobrir o caso em que uma tarefa terminou antes da assinatura ser estabelecida. Retorna resultados para todas as tarefas independentemente de sucesso ou falha — verifique a chave `\"error\"` nos dicts individuais se necessário.\n\n**Retorna:** `{título_da_tarefa: dict_resultado}`\n\nTarefas que não concluírem dentro do `timeout` em segundos são registradas como aviso; sua entrada no dict de resultado será `{}`.\n\n```python\nids = ctx.spawn_tasks([...])\nresultados = ctx.wait_for_tasks(ids, timeout=120)\n\npesquisa = resultados.get(\"Pesquisa de Mercado\", {})\nconcorrente = resultados.get(\"Análise de Concorrentes\", {})\n\nctx.write_memory(\"combinado\", {**pesquisa, **concorrente})\nreturn {\"merged\": resultados}\n```\n\n---\n\n## Adaptadores\n\nAdaptadores encapsulam objetos de frameworks de terceiros para que seu output esteja em conformidade com o tipo de retorno `dict` esperado pelo `@osy.agent`. Cada adaptador está em `osymandias.adapters.*`.\n\n---\n\n### LangChain\n\n```bash\npip install osymandias[langchain]\n```\n\n```python\nfrom osymandias.adapters.langchain import LangChainAdapter\n```\n\nEncapsula qualquer **LCEL Runnable** do LangChain (chains, pipelines `prompt | llm | parser`) ou **AgentExecutor** legado. Automaticamente anexa um `OsyCallbackHandler` que encaminha eventos `on_llm_start`, `on_tool_start`, etc. como eventos `TASK_PROGRESS` para o dashboard.\n\n```python\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_ollama import ChatOllama\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.langchain import LangChainAdapter\n\nllm = ChatOllama(model=\"qwen2.5:7b\")\nprompt = ChatPromptTemplate.from_messages([\n (\"system\", \"Você é um assistente de pesquisa.\"),\n (\"human\", \"{input}\"),\n])\nchain = prompt | llm\n\n@osy.agent(\"ResearchAgent\", framework=\"langchain\", llm_model=\"qwen2.5:7b\")\ndef research_agent(task: str, ctx: OsyContext) -> dict:\n return LangChainAdapter(chain).run(task, ctx=ctx)\n```\n\n**`LangChainAdapter.run(task, ctx=None) → dict`**\n\nInvoca a chain com `{\"input\": task}`. Se `ctx` for fornecido, anexa o callback handler. Normaliza o resultado para `dict` automaticamente (lida com `str`, modelos Pydantic e dicts brutos).\n\n---\n\n### CrewAI\n\n```bash\npip install osymandias[crewai]\n```\n\n```python\nfrom osymandias.adapters.crewai import CrewAIAdapter\n```\n\nEncapsula um objeto `Crew`. O crew executa como uma caixa-preta — handoffs internos entre agentes dentro do crew não são rastreados individualmente no OSymandias. Use `ctx.emit_event` manualmente dentro de callbacks do CrewAI se precisar de progresso intermediário.\n\n```python\nfrom crewai import Agent, Crew, Task\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.crewai import CrewAIAdapter\n\npesquisador = Agent(role=\"Pesquisador\", goal=\"Encontrar dados precisos\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\nanalista = Agent(role=\"Analista\", goal=\"Interpretar os achados\", backstory=\"...\", llm=\"ollama/qwen2.5:7b\")\n\ncrew = Crew(\n agents=[pesquisador, analista],\n tasks=[\n Task(description=\"Pesquise {task}\", agent=pesquisador),\n Task(description=\"Analise os achados\", agent=analista),\n ],\n verbose=False,\n)\n\n@osy.agent(\"AnalystCrew\", framework=\"crewai\")\ndef analyst_crew(task: str, ctx: OsyContext) -> dict:\n ctx.emit_event(\"TASK_PROGRESS\", {\"step\": \"crew iniciando\"})\n resultado = CrewAIAdapter(crew).run(task, ctx=ctx)\n ctx.write_memory(\"output_crew\", resultado)\n return resultado\n```\n\n**`CrewAIAdapter.run(task, ctx=None) → dict`**\n\nChama `crew.kickoff(inputs={\"task\": task})`. Normaliza `CrewOutput.raw` para `dict`.\n\n---\n\n### LlamaIndex\n\n```bash\npip install osymandias[llamaindex]\n```\n\n```python\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n```\n\nEncapsula um `QueryEngine` (usa `.query()`) ou um `ReActAgent` (usa `.chat()`). Nós fonte da recuperação são incluídos no resultado em `\"sources\"`. Imagens encontradas nos metadados dos nós fonte são coletadas em `\"_media\"` para renderização multimodal no dashboard.\n\n```python\nfrom llama_index.core import VectorStoreIndex, SimpleDirectoryReader\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.llamaindex import LlamaIndexAdapter\n\ndocs = SimpleDirectoryReader(\"./data\").load_data()\nindex = VectorStoreIndex.from_documents(docs)\nengine = index.as_query_engine()\n\n@osy.agent(\"RAGAgent\", framework=\"llamaindex\", description=\"Responde perguntas a partir de documentos locais\")\ndef rag_agent(task: str, ctx: OsyContext) -> dict:\n return LlamaIndexAdapter(engine).run(task, ctx=ctx)\n```\n\n**`LlamaIndexAdapter.run(task, ctx=None) → dict`**\n\nRetorna `{\"output\": str, \"sources\": list[str], \"_media\": list[dict]}`.\n\n---\n\n### Smolagents\n\n```bash\npip install osymandias[smolagents]\n```\n\n```python\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n```\n\nEncapsula qualquer agente HuggingFace Smolagents. Se o agente retornar um `matplotlib.figure.Figure` ou `PIL.Image`, é codificado em base64 e colocado em `\"_media\"` para renderização no dashboard.\n\n```python\nfrom smolagents import CodeAgent, HfApiModel\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.smolagents import SmolAgentsAdapter\n\nagent = CodeAgent(\n tools=[],\n model=HfApiModel(\"meta-llama/Llama-3.2-3B-Instruct\"),\n)\n\n@osy.agent(\"HFCoder\", framework=\"smolagents\")\ndef hf_agent(task: str, ctx: OsyContext) -> dict:\n return SmolAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`SmolAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `agent.run(task)`. Retorna `{\"output\": str}` para texto, ou `{\"output\": \"figure\", \"_media\": [...]}` para outputs de imagem.\n\n---\n\n### OpenAI Agents SDK\n\n```bash\npip install osymandias[openai-agents]\n```\n\n```python\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n```\n\nEncapsula um `Agent` do OpenAI Agents SDK. Handoffs de agentes são emitidos como eventos `TASK_PROGRESS` para que a cadeia de handoffs seja visível no stream SSE do dashboard.\n\n```python\nfrom agents import Agent\nfrom osymandias import osy, OsyContext\nfrom osymandias.adapters.openai_agents import OpenAIAgentsAdapter\n\nagent = Agent(\n name=\"Assistente\",\n instructions=\"Você é um assistente prestativo.\",\n model=\"gpt-4o\",\n)\n\n@osy.agent(\"GPT4Agent\", framework=\"openai-agents\", llm_provider=\"openai\", llm_model=\"gpt-4o\")\ndef gpt4_agent(task: str, ctx: OsyContext) -> dict:\n return OpenAIAgentsAdapter(agent).run(task, ctx=ctx)\n```\n\n**`OpenAIAgentsAdapter.run(task, ctx=None) → dict`**\n\nChama `Runner.run_sync(agent, task)`. Emite um evento `TASK_PROGRESS` por mensagem de handoff se `ctx` for fornecido.\n\n---\n\n## Enviando jobs via API\n\n### Job em linguagem natural\n\nDeixe o PlannerAgent decompor o objetivo em tarefas automaticamente:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024 e escreva um relatório estruturado.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {}\n }'\n```\n\n**Valores de prioridade:** `\"LOW\"` · `\"NORMAL\"` · `\"HIGH\"` · `\"CRITICAL\"`\n\n### Plano de tarefas explícito (`__task_plan__`)\n\nIgnore o PlannerAgent completamente fornecendo uma lista de tarefas diretamente em `input_payload`. Útil quando você sabe exatamente quais agentes executar e em qual ordem:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquisa de mercado EV\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {\n \"__task_plan__\": [\n {\"title\": \"Pesquisa\", \"agent_type\": \"ResearchAgent\", \"description\": \"Mercado EV na Europa 2024\"},\n {\"title\": \"Relatório\", \"agent_type\": \"WriterAgent\", \"description\": \"Escreva um relatório estruturado a partir dos achados\"}\n ]\n }\n }'\n```\n\nCada entrada em `__task_plan__` aceita as mesmas chaves que os task defs do `ctx.spawn_tasks`: `title` (obrigatório), `agent_type`, `description`.\n\n### Resubmissão de um job\n\nCopia a entrada de um job concluído ou com falha e cria uma nova execução:\n\n```bash\ncurl -X POST http://localhost:47760/api/v1/jobs//resubmit\n```\n\nRetorna o novo objeto de job. O job original permanece inalterado. O botão de resubmissão também está disponível na view de detalhes do job no dashboard para qualquer job em estado terminal (COMPLETED, FAILED, CANCELLED).\n\n### Python\n\n```python\nimport httpx\n\nresp = httpx.post(\"http://localhost:47760/api/v1/jobs\", json={\n \"title\": \"Relatório Mercado EV\",\n \"description\": \"Pesquise o mercado europeu de veículos elétricos em 2024.\",\n \"priority\": \"NORMAL\",\n \"input_payload\": {},\n})\njob = resp.json()\nprint(job[\"id\"])\n```\n\nDocumentação interativa completa da API: **http://localhost:47760/api/v1/docs**\n\n---\n\n## Páginas do dashboard\n\n| Página | URL | Descrição |\n|------|-----|-------------|\n| Jobs | `/jobs` | Lista de jobs — busca, filtro por status, paginação |\n| Detalhe do job | `/jobs/{id}` | Visualizador de output (JSON/markdown/imagem/áudio), feed de eventos, árvore de tarefas, botão de resubmissão |\n| Agentes | `/agents` | Registro de agentes — nativos e externos, painel de detalhes adaptativo, filtro por tipo/framework |\n| Ferramentas | `/tools` | Ferramentas nativas e funções `@osy.tool` |\n| Memória | `/memory` | Navegar e buscar entradas de memória de jobs/agentes, deletar chaves individuais |\n| Eventos | `/events` | Stream de eventos global ao vivo — pausar/retomar, filtrar por job |\n| Métricas | `/metrics` | Gráficos de 7 dias: jobs, tokens, estimativa de custo, taxa de sucesso |\n\n### Preview de output ao vivo\n\nEnquanto um job está em execução, a aba OUTPUT exibe um preview ao vivo das tarefas em andamento. Cada vez que um agente chama `ctx.emit_event(\"TASK_PROGRESS\", {...})`, o dashboard atualiza o card da tarefa em tempo real via SSE — sem polling. Quando o job conclui e `output_payload` está disponível, o output final substitui o preview automaticamente.\n\n---\n\n## Provedores de LLM suportados\n\nConfigurado durante o `osy init` ou editando o `.env` diretamente.\n\n| Provedor | Chave no `.env` | Exemplo de modelo |\n|---|---|---|\n| Ollama (local) | *(nenhuma necessária)* | `llama3.2`, `qwen2.5:7b` |\n| OpenAI | `OPENAI_API_KEY` | `gpt-4o`, `gpt-4o-mini` |\n| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |\n| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |\n| Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |\n| Gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |\n\nModelos podem ser alterados por agente no dashboard sem reiniciar o runtime.\n\n---\n\n## Escalabilidade\n\nO OSymandias escala horizontalmente executando processos Celery worker adicionais em máquinas separadas. O servidor de API, o scheduler e os workers são independentes — apenas o RabbitMQ (fila de tarefas) e o Redis (resultados + pub/sub) precisam ser acessíveis por todos os nós.\n\n### Arquitetura de filas\n\n| Fila | Responsabilidade | Recomendação de concorrência |\n|---|---|---|\n| `scheduler` | Resolução de DAG, despacho de jobs | 1 (worker único, evita race conditions) |\n| `agents` | Loops de agentes (chamadas de LLM) | Escale — maior uso de CPU/espera aqui |\n| `tools` | Chamadas `@osy.tool` e webhooks | Escale junto com workers de agentes |\n| `evaluator` | Pontuação de output | Volume baixo; 1-2 slots suficientes |\n\nA fila `scheduler` deve permanecer em uma máquina (a que executa `osy serve`). `agents`, `tools` e `evaluator` podem ser distribuídas livremente.\n\n### Concorrência local\n\nAumente slots em uma única máquina via `--concurrency` ou `.env`:\n\n```bash\n# .env\nOSY_WORKER_CONCURRENCY=8\n\n# ou na inicialização\nosy serve --concurrency 8\n```\n\n### Escalabilidade horizontal (múltiplas máquinas)\n\n```bash\n# ── Máquina A — executa API + scheduler ───────────────────────────\nosy serve --no-docker # ou com Docker\n\n# ── Máquina B — workers de agentes extras ─────────────────────────\n# Aponte para o broker/redis compartilhado no .env ou variáveis de ambiente\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues agents,tools --concurrency 8\n\n# ── Máquina C — evaluator dedicado ────────────────────────────────\nOSY_RABBITMQ_URL=amqp://user:pass@maquina-a:47764/ \\\nOSY_REDIS_URL=redis://maquina-a:47763/0 \\\nosy workers --queues evaluator --concurrency 2\n```\n\nOs nós worker precisam ter `osymandias` instalado e acesso ao mesmo `osymandias.toml` (ou aos mesmos módulos `@osy.agent` importáveis). Eles **não** precisam de acesso ao PostgreSQL — o servidor de API é o único processo que fala diretamente com o Postgres.\n\n### Kubernetes / deployments em container\n\nCada worker Celery é um processo stateless. Um deployment típico:\n\n| Deployment | Comando | Réplicas |\n|---|---|---|\n| API | `uvicorn osymandias.runtime.main:app` | 1-2 |\n| Workers de agentes | `celery -A osymandias.runtime.workers.celery_app worker --queues agents,tools` | N |\n| Scheduler | `celery -A osymandias.runtime.workers.celery_app worker --queues scheduler --concurrency 1` | 1 |\n| Beat | `celery -A osymandias.runtime.workers.celery_app beat` | 1 |\n\nTodos os containers worker compartilham o mesmo broker RabbitMQ e backend Redis via variáveis de ambiente.\n\n---\n\n## Dependências opcionais\n\n| Extra | Instala |\n|---|---|\n| `osymandias[langchain]` | `langchain-core`, `langchain` |\n| `osymandias[crewai]` | `crewai` |\n| `osymandias[llamaindex]` | `llama-index-core` |\n| `osymandias[smolagents]` | `smolagents` |\n| `osymandias[openai-agents]` | `openai-agents` |\n| `osymandias[all]` | Todos acima |\n\nO pacote base `osymandias` não tem dependências de framework — os extras só são necessários se você usar o adaptador correspondente.\n"}}}}