From e8a0f0e7bc5dbe2c7afcb097bb0d746d4a25284e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 04:28:21 +0000 Subject: [PATCH 01/28] feat(agent-runtime): autonomous OpenMontage agent runtime for gateway mode=agent (Gap B Phase 1) OpenMontage has no Python pipeline runner (host LLM is the orchestrator), so mode=agent needs a new unattended runtime. Adds services/agent_runtime/: - llm.py: urllib-only cloud LLM client (anthropic|openai|kakeya|stub, auto-detect from keys). - run.py: LLM -> brief/script/scene_plan (schema-validated + checkpointed) -> assets via video_selector (auto best provider) + tts_selector + music_gen -> edit -> audio_mixer + video_compose (ffmpeg) -> final mp4. AGENT_RUNTIME_FAKE_ASSETS=1 for offline CI. - tests: stub-LLM + ffmpeg end-to-end (valid h264 + checkpoints) + JSON extraction. 23 pass. ADR 0016 + loop-log iteration 41. Needs a cloud LLM key to run live; quality is provider-bounded. Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 26 ++ docs/adr/0016-autonomous-agent-runtime.md | 71 +++++ services/agent_runtime/__init__.py | 1 + services/agent_runtime/llm.py | 117 ++++++++ services/agent_runtime/run.py | 277 +++++++++++++++++++ tests/tools/test_agent_runtime.py | 78 ++++++ 6 files changed, 570 insertions(+) create mode 100644 docs/adr/0016-autonomous-agent-runtime.md create mode 100644 services/agent_runtime/__init__.py create mode 100644 services/agent_runtime/llm.py create mode 100644 services/agent_runtime/run.py create mode 100644 tests/tools/test_agent_runtime.py diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index 9b5128f76..6c2631e78 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1122,6 +1122,32 @@ auto-recovery. (Caveat: the ~84 G I2V model re-downloads unless `HF_HOME` is on --- +## Iteration 41 — Gap B Phase 1: autonomous agent runtime (mode=agent) — ADR 0016 + +**Finding:** OpenMontage has **no Python pipeline runner** — the host LLM is normally the +orchestrator. So `mode=agent` integration = building a new **autonomous agent runtime** (a cloud-LLM +reasoner + tool-calling loop). In-repo LLM is only `kakeya_llm` (mechanical). + +**Built:** `services/agent_runtime/` (`AGENT_RUNTIME_CMD`): +- `llm.py` — urllib-only cloud LLM client (`AGENT_LLM=anthropic|openai|kakeya|stub`, auto-detect). +- `run.py` — unattended orchestrator: LLM → brief→script→scene_plan (schema-validated + checkpointed + via `lib/checkpoint`), assets via `video_selector` (auto-routes to best provider; premium if + configured, else WAN draft) + `tts_selector` + `music_gen`, deterministic edit → `audio_mixer` + + `video_compose` (ffmpeg) → final mp4. `AGENT_RUNTIME_FAKE_ASSETS=1` for CI. + +**Topology:** agent runtime on the head Mac CPU (cloud LLM, doesn't steal video GPU); headless = video +worker; vast = heavy gen — the cost/stability recommendation. + +**Validated offline (no keys/GPU):** stub LLM + ffmpeg fixtures → full plan→validate→checkpoint→ +assets→mix→compose → valid h264 mp4 + checkpoints. 23 tests pass (runtime+gateway+pipeline). + +**Honest scope:** MVP "auto" path (no research/proposal/approval/reviewer governance). **Needs a +cloud LLM key** to actually run; quality is provider-bounded (WAN draft unless a premium video +provider key is set — then `video_selector` auto-upgrades). Not yet "all skills". Live `mode=agent` +test on agent.kakeya.ai pending the key. + +--- + ## Open follow-ups (next iterations) - **Phase 2b — native gRPC transport.** Add an optional `kakeya` Python SDK transport for the bounded-memory long-context path (W3), behind the same tool, once the proto diff --git a/docs/adr/0016-autonomous-agent-runtime.md b/docs/adr/0016-autonomous-agent-runtime.md new file mode 100644 index 000000000..df65f7bff --- /dev/null +++ b/docs/adr/0016-autonomous-agent-runtime.md @@ -0,0 +1,71 @@ +# ADR 0016 — Autonomous agent runtime for the public gateway (Gap B, Phase 1) + +**Status:** Accepted; MVP implemented + offline-validated. Live run pending a cloud LLM key. +**Date:** 2026-06-23 +**Related:** ADR 0011/0013 (agent gateway, re-anchor), AGENT_GUIDE.md (instruction-driven model). + +## 1. Problem (Gap B) + +`agent.kakeya.ai` only did **bare text→video** through the WAN cluster. The gateway's `mode=agent` +requires an `AGENT_RUNTIME_CMD`, which was unattached (`agent_runtime=false`) — so none of +OpenMontage's pipeline/skills (script → scene_plan → assets via selectors → compose) ran. The user +asked to **integrate the OpenMontage agent** so the public service runs a real multi-stage pipeline. + +## 2. Key finding + +OpenMontage is **instruction-driven**: there is **no Python pipeline runner** — the *host LLM* +(Cursor/Claude) is normally the orchestrator, reading manifests + director skills and driving tools. +To run **unattended** behind the gateway, we must supply our own LLM reasoner. In-repo LLM access is +only `kakeya_llm`/`llm_selector` (mechanical text). So this ADR introduces a new **autonomous agent +runtime**. + +## 3. Decision + +New module `services/agent_runtime/` exposed as `AGENT_RUNTIME_CMD` +(`python -m services.agent_runtime.run --prompt … --out …`): + +- **`llm.py`** — dependency-free (urllib) cloud-LLM client: `AGENT_LLM = anthropic|openai|kakeya|stub` + (auto-detected from `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`KAKEYA_ENDPOINT`). `complete_json` parses + + retries. Creative reasoning runs here; the host agent is no longer required at runtime. +- **`run.py`** — minimal unattended orchestrator that reuses the real machinery: + - LLM produces **brief → script → scene_plan** (each schema-validated via `schemas.artifacts` + and checkpointed via `lib/checkpoint`, exactly like the governed pipeline; director-skill text + is loaded as LLM context — "intelligence in the skills"). + - assets: per-scene video via **`video_selector`** (auto-routes to the BEST available provider — + premium Seedance/Kling/Veo if configured, else the local WAN draft cluster), narration via + **`tts_selector`**, one music track via **`music_gen`** (skipped when unconfigured). + - deterministic **edit_decisions** → **`audio_mixer`** + **`video_compose`** (ffmpeg) → final mp4. +- `AGENT_RUNTIME_FAKE_ASSETS=1` swaps providers for ffmpeg fixtures so the harness is CI-testable + with no keys/GPU. + +**Topology** (per the cost/stability recommendation): agent runtime runs on the **head Mac CPU** +(cloud LLM) next to the gateway; headless Mac stays a video worker; vast does heavy generation. The +agent uses a cloud LLM (light CPU/network) — it does **not** steal GPU from video. + +## 4. Honest scope / limits + +- This is an **MVP "auto" path**, not the full governed `animated-explainer` pipeline (no + research/proposal, human-approval gates, reviewer governance, or per-stage director nuance). +- **Needs a cloud LLM key** to reason (none configured). Without it, `mode=agent` stays honestly + "not attached". +- **Output quality is provider-bounded.** With only the WAN cluster, clips are draft quality. + Configure a **premium video provider** (e.g. fal.ai/Seedance) and `video_selector` auto-upgrades — + that is the real path to production-grade. TTS/music/images need their own keys. +- Not "all skills integrated": skills light up as their providers are configured + as more pipelines + are wired. This is the first integration, not the finish line. + +## 5. Validation + +`tests/tools/test_agent_runtime.py` (offline, no keys/GPU): stub LLM emits valid brief/script/ +scene_plan; `AGENT_RUNTIME_FAKE_ASSETS=1` → full plumbing (plan → validate → checkpoint → per-scene +assets → audio mix → ffmpeg compose) → **valid h264 mp4** + checkpoints for idea/script/scene_plan/ +assets/edit. JSON-extraction unit test. 2 pass (23 with gateway+pipeline suites). + +## 6. Next + +- Deploy: set `AGENT_RUNTIME_CMD=" -m services.agent_runtime.run"` + a cloud LLM key on the + head; live-test `mode=agent` via `agent.kakeya.ai`. +- Configure premium video provider for production-grade clips (the quality answer from the + draft-vs-premium discussion). +- Grow toward the governed pipeline (proposal/approval/reviewer, more director skills) and more + skills (avatar, images, music) as keys are added. diff --git a/services/agent_runtime/__init__.py b/services/agent_runtime/__init__.py new file mode 100644 index 000000000..6101e6540 --- /dev/null +++ b/services/agent_runtime/__init__.py @@ -0,0 +1 @@ +"""OpenMontage autonomous agent runtime (gateway mode=agent).""" diff --git a/services/agent_runtime/llm.py b/services/agent_runtime/llm.py new file mode 100644 index 000000000..cc86642bf --- /dev/null +++ b/services/agent_runtime/llm.py @@ -0,0 +1,117 @@ +"""LLM client for the OpenMontage agent runtime. + +The OpenMontage "agent" is normally the host LLM (Cursor/Claude). To run the pipeline +UNATTENDED behind the gateway's mode=agent, we need our own cloud-LLM reasoner. This is a +thin, dependency-free client (urllib only) over the providers' HTTP APIs, selected by env: + + AGENT_LLM = anthropic | openai | kakeya | stub (auto-detected from keys if unset) + ANTHROPIC_API_KEY / OPENAI_API_KEY / KAKEYA_ENDPOINT + AGENT_LLM_MODEL (optional override) + +Creative reasoning (brief/script/scene_plan/edit) runs here; mechanical text can still use +the in-repo kakeya_llm tool. `complete_json` returns a parsed dict, retrying once on bad JSON. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.request +from typing import Any, Callable, Optional + +_DEFAULT_MODELS = { + "anthropic": "claude-3-5-sonnet-latest", + "openai": "gpt-4o-mini", + "kakeya": "kakeya-local", +} + + +class LLM: + def __init__(self, provider: Optional[str] = None, model: Optional[str] = None, + stub: Optional[Callable[[str, str], str]] = None): + self.stub = stub + self.provider = (provider or os.environ.get("AGENT_LLM", "")).strip().lower() + if not self.provider: + if stub is not None: + self.provider = "stub" + elif os.environ.get("ANTHROPIC_API_KEY"): + self.provider = "anthropic" + elif os.environ.get("OPENAI_API_KEY"): + self.provider = "openai" + elif os.environ.get("KAKEYA_ENDPOINT"): + self.provider = "kakeya" + else: + self.provider = "stub" + self.model = model or os.environ.get("AGENT_LLM_MODEL", "") or _DEFAULT_MODELS.get(self.provider, "") + + # --- transports --------------------------------------------------------- + def complete(self, system: str, user: str, max_tokens: int = 3000) -> str: + if self.provider == "stub": + if self.stub is None: + raise RuntimeError("AGENT_LLM=stub but no stub callable provided") + return self.stub(system, user) + if self.provider == "anthropic": + return self._anthropic(system, user, max_tokens) + if self.provider == "openai": + return self._openai(system, user, max_tokens) + if self.provider == "kakeya": + return self._openai(system, user, max_tokens, kakeya=True) + raise RuntimeError(f"unknown AGENT_LLM provider: {self.provider}") + + def _post(self, url: str, headers: dict, body: dict, timeout: int = 180) -> dict: + req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + + def _anthropic(self, system: str, user: str, max_tokens: int) -> str: + out = self._post( + "https://api.anthropic.com/v1/messages", + {"Content-Type": "application/json", "x-api-key": os.environ["ANTHROPIC_API_KEY"], + "anthropic-version": "2023-06-01"}, + {"model": self.model, "max_tokens": max_tokens, "system": system, + "messages": [{"role": "user", "content": user}]}) + return "".join(b.get("text", "") for b in out.get("content", [])) + + def _openai(self, system: str, user: str, max_tokens: int, kakeya: bool = False) -> str: + if kakeya: + base = os.environ["KAKEYA_ENDPOINT"].rstrip("/") + headers = {"Content-Type": "application/json"} + else: + base = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com").rstrip("/") + headers = {"Content-Type": "application/json", + "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"} + out = self._post(base + "/v1/chat/completions", headers, + {"model": self.model, "max_tokens": max_tokens, + "messages": [{"role": "system", "content": system}, + {"role": "user", "content": user}]}) + return out["choices"][0]["message"]["content"] + + # --- JSON helper -------------------------------------------------------- + @staticmethod + def _extract_json(text: str) -> dict: + # strip ```json fences, then take the outermost {...} + text = re.sub(r"```(?:json)?", "", text).strip() + start, depth = text.find("{"), 0 + if start < 0: + raise ValueError("no JSON object in LLM output") + for i in range(start, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return json.loads(text[start:i + 1]) + raise ValueError("unbalanced JSON in LLM output") + + def complete_json(self, system: str, user: str, retries: int = 1) -> dict: + last = None + for attempt in range(retries + 1): + txt = self.complete(system, user + ("\n\nReturn ONLY valid JSON." if attempt == 0 + else f"\n\nYour previous output failed to parse ({last}). " + "Return ONLY a single valid JSON object, no prose.")) + try: + return self._extract_json(txt) + except Exception as exc: # noqa: BLE001 + last = exc + raise RuntimeError(f"LLM did not return valid JSON after {retries + 1} attempts: {last}") diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py new file mode 100644 index 000000000..668de16dd --- /dev/null +++ b/services/agent_runtime/run.py @@ -0,0 +1,277 @@ +"""OpenMontage autonomous agent runtime (MVP) — `AGENT_RUNTIME_CMD` for the gateway's mode=agent. + +OpenMontage is instruction-driven (the host LLM is normally the orchestrator). This module is a +minimal UNATTENDED orchestrator so the public gateway can run a real multi-stage pipeline from a +brief, instead of a bare text->video call: + + brief (LLM) -> script (LLM) -> scene_plan (LLM) + -> assets: per-scene video via video_selector (auto-routes to the BEST available provider: + premium Seedance/Kling/Veo if configured, else the local WAN draft cluster), + + narration via tts_selector, + one music track via music_gen (when available) + -> edit_decisions (deterministic cuts) -> compose via audio_mixer + video_compose (ffmpeg) + -> final mp4 at --out. + +Each canonical artifact is schema-validated and checkpointed (lib/checkpoint), exactly like the +governed pipeline. Creative decisions come from the LLM; only mechanical wiring is in code. + +Honest scope: this is an MVP "auto" path, not the full governed animated-explainer pipeline +(no research/proposal/human-approval/reviewer governance). Output quality is bounded by the +configured providers — configure a premium video provider key to get production-grade clips; +with only the WAN cluster it is draft quality. + +CLI: python -m services.agent_runtime.run --prompt "..." --out /path/final.mp4 +Env: AGENT_LLM + key (see llm.py). AGENT_RUNTIME_FAKE_ASSETS=1 -> ffmpeg fixtures (offline tests). +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import uuid +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + +from services.agent_runtime.llm import LLM # noqa: E402 + +FAKE = os.environ.get("AGENT_RUNTIME_FAKE_ASSETS", "0").lower() in ("1", "true", "yes") +MAX_SCENES = int(os.environ.get("AGENT_MAX_SCENES", "5")) +CLIP_W = int(os.environ.get("AGENT_CLIP_WIDTH", "1280")) +CLIP_H = int(os.environ.get("AGENT_CLIP_HEIGHT", "720")) + + +def log(msg: str): + print(f"[agent] {msg}", flush=True) + + +def _skill(path: str) -> str: + """Best-effort director-skill text for LLM context (truncated). Intelligence lives in skills.""" + for cand in (REPO / "skills" / f"{path}.md", REPO / f"{path}.md"): + try: + return cand.read_text()[:6000] + except OSError: + continue + return "" + + +# --------------------------------------------------------------------------- # +# asset helpers (real providers via the registry, or ffmpeg fixtures for tests) +# --------------------------------------------------------------------------- # +def _ffmpeg_clip(out: str, seconds: float, color: str = "darkblue"): + subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", + f"color=c={color}:s={CLIP_W}x{CLIP_H}:d={max(1, int(seconds))}:r=24", + "-c:v", "libx264", "-pix_fmt", "yuv420p", out], capture_output=True, check=True) + + +def _ffmpeg_sine(out: str, seconds: float): + subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", + f"sine=frequency=320:duration={max(1, int(seconds))}", "-ar", "44100", "-ac", "1", out], + capture_output=True, check=True) + + +def generate_clip(registry, prompt: str, out: str, seconds: float, idx: int) -> str: + if FAKE: + _ffmpeg_clip(out, seconds, ["darkblue", "darkgreen", "darkred", "purple", "teal"][idx % 5]) + return out + sel = registry.get("video_selector") + if sel is None: + raise RuntimeError("no video_selector tool available") + res = sel.execute({"prompt": prompt, "aspect_ratio": "16:9", "duration": str(int(seconds)), + "output_path": out}) + if not res.success: + raise RuntimeError(f"video generation failed: {res.error}") + return res.data.get("output") or res.data.get("output_path") or out + + +def generate_tts(registry, text: str, out: str) -> str | None: + if FAKE: + _ffmpeg_sine(out, max(2, len(text) // 15)); return out + avail = [t for t in registry.get_by_capability("tts") if getattr(t, "name", "") != "tts_selector" + and _tool_available(t)] + if not avail: + return None + res = registry.get("tts_selector").execute({"text": text, "output_path": out}) + return (res.data.get("output") or out) if res.success else None + + +def generate_music(registry, prompt: str, out: str, seconds: float) -> str | None: + if FAKE: + _ffmpeg_sine(out, seconds); return out + mg = registry.get("music_gen") + if mg is None or not _tool_available(mg): + return None + res = mg.execute({"prompt": prompt, "duration_seconds": int(seconds), "output_path": out}) + return (res.data.get("output") or out) if res.success else None + + +def _tool_available(tool) -> bool: + try: + info = tool.get_info() + return str(info.get("status", "")).lower() in ("available", "ready", "ok", "") + except Exception: # noqa: BLE001 + return True + + +# --------------------------------------------------------------------------- # +# LLM planning stages +# --------------------------------------------------------------------------- # +def plan_brief(llm: LLM, prompt: str) -> dict: + sys_p = ("You are OpenMontage's idea director. Output a `brief` JSON artifact. " + "Required keys: version='1.0', title, hook, key_points (array of 3-6 strings), " + "tone, style, target_platform, target_duration_seconds (integer). " + _skill("pipelines/explainer/idea-director")) + return llm.complete_json(sys_p, f"User brief: {prompt}") + + +def plan_script(llm: LLM, brief: dict) -> dict: + dur = int(brief.get("target_duration_seconds", 20)) + sys_p = ("You are OpenMontage's script director. Output a `script` JSON artifact. Required: " + "version='1.0', title, total_duration_seconds (integer), sections (array). Each section: " + "id, text, start_seconds, end_seconds (contiguous, covering 0..total). Keep it concise. " + + _skill("pipelines/explainer/script-director")) + return llm.complete_json(sys_p, f"Brief:\n{json.dumps(brief)}\nTotal duration ~{dur}s.") + + +def plan_scenes(llm: LLM, script: dict) -> dict: + sys_p = ("You are OpenMontage's scene director. Output a `scene_plan` JSON artifact. Required: " + "version='1.0', scenes (array). Each scene: id, type (one of generated|text_card|" + "animation|diagram), description (a vivid visual prompt for a video generator), " + "start_seconds, end_seconds, script_section_id. One scene per script section, contiguous. " + + _skill("pipelines/explainer/scene-director")) + return llm.complete_json(sys_p, f"Script:\n{json.dumps(script)}") + + +# --------------------------------------------------------------------------- # +# orchestration +# --------------------------------------------------------------------------- # +def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None = None) -> str: + from lib.checkpoint import write_checkpoint + from schemas.artifacts import validate_artifact + from tools.tool_registry import registry + + registry.discover() + llm = llm or LLM() + log(f"LLM provider={llm.provider} model={llm.model or '(default)'} fake_assets={FAKE}") + pid = "agent_" + uuid.uuid4().hex[:8] + proj = project_dir or (REPO / "projects" / "_agent" / pid) + pdir = proj / "pipeline" + assets = proj / "assets" + assets.mkdir(parents=True, exist_ok=True) + # pipeline_type=None -> validate against ALL known stages (this MVP uses the canonical + # idea->script->scene_plan->assets->edit->compose path, not a specific manifest's stage set). + ck = lambda stage, art: write_checkpoint(pdir, pid, stage, "completed", artifacts=art, + pipeline_type=None) # noqa: E731 + + # 1) brief -> 2) script -> 3) scene_plan (LLM, validated, checkpointed) + log("planning brief...") + brief = plan_brief(llm, prompt); brief.setdefault("version", "1.0") + validate_artifact("brief", brief); ck("idea", {"brief": brief}) + log(f"brief: {brief.get('title')}") + + log("planning script...") + script = plan_script(llm, brief); script.setdefault("version", "1.0") + validate_artifact("script", script); ck("script", {"script": script}) + + log("planning scenes...") + scene_plan = plan_scenes(llm, script); scene_plan.setdefault("version", "1.0") + scenes = scene_plan.get("scenes", [])[:MAX_SCENES] + scene_plan["scenes"] = scenes + validate_artifact("scene_plan", scene_plan); ck("scene_plan", {"scene_plan": scene_plan}) + log(f"{len(scenes)} scenes") + + # 4) assets: per-scene video (video_selector -> best provider) + narration; one music track + sect_by_id = {s["id"]: s for s in script.get("sections", [])} + asset_list, cuts = [], [] + for i, sc in enumerate(scenes): + dur = max(2.0, float(sc.get("end_seconds", 0)) - float(sc.get("start_seconds", 0)) or 5.0) + vid = str(assets / f"scene_{i}.mp4") + log(f"scene {i}: generating clip ({dur:.0f}s) — {sc.get('description','')[:60]}") + vid = generate_clip(registry, sc.get("description", prompt), vid, dur, i) + asset_list.append({"id": f"v{i}", "type": "video", "path": vid, "source_tool": "video_selector", + "scene_id": sc.get("id", f"sc{i}")}) + cuts.append({"id": f"cut{i}", "source": vid, "in_seconds": 0.0, "out_seconds": dur, "speed": 1.0}) + # narration from the linked script section + sec = sect_by_id.get(sc.get("script_section_id")) + if sec and sec.get("text"): + nar = generate_tts(registry, sec["text"], str(assets / f"nar_{i}.mp3")) + if nar: + asset_list.append({"id": f"n{i}", "type": "narration", "path": nar, + "source_tool": "tts_selector", "scene_id": sc.get("id", f"sc{i}")}) + + total = sum(c["out_seconds"] for c in cuts) + music = generate_music(registry, f"background music, {brief.get('tone','')}", + str(assets / "music.mp3"), total) + if music: + asset_list.append({"id": "music", "type": "music", "path": music, "source_tool": "music_gen", + "scene_id": scenes[0].get("id", "sc0") if scenes else "sc0"}) + asset_manifest = {"version": "1.0", "assets": asset_list} + validate_artifact("asset_manifest", asset_manifest); ck("assets", {"asset_manifest": asset_manifest}) + + # 5) edit_decisions (deterministic: clips in order, ffmpeg runtime) + narrations = [a["path"] for a in asset_list if a["type"] == "narration"] + edit = {"version": "1.0", "cuts": cuts, "render_runtime": "ffmpeg"} + if music: + edit["music"] = {"asset_id": "music", "volume": 0.2, "ducking": True} + validate_artifact("edit_decisions", edit); ck("edit", {"edit_decisions": edit}) + + # 6) compose: mix narration+music, then concat clips with audio + from tools.video.video_compose import VideoCompose + audio_path = None + if narrations or music: + audio_path = str(assets / "mix.wav") + _build_audio(narrations, music, audio_path) + log("composing final video (ffmpeg)...") + res = VideoCompose().execute({ + "operation": "compose", + "edit_decisions": {"cuts": [{"source": c["source"], "in_seconds": c["in_seconds"], + "out_seconds": c["out_seconds"], "speed": 1.0} for c in cuts]}, + **({"audio_path": audio_path} if audio_path else {}), + "output_path": out, "codec": "libx264", "crf": 23, "preset": "fast"}) + if not res.success or not Path(out).exists(): + raise RuntimeError(f"compose failed: {res.error}") + render_report = {"version": "1.0", "outputs": [{"path": out, "format": "mp4", "codec": "h264", + "resolution": f"{CLIP_W}x{CLIP_H}", "duration_seconds": round(total, 2)}]} + try: + validate_artifact("render_report", render_report); ck("compose", {"render_report": render_report}) + except Exception as exc: # noqa: BLE001 + log(f"render_report checkpoint skipped: {exc}") + log(f"done -> {out} ({brief.get('title')}, {len(scenes)} scenes, ~{total:.0f}s)") + return out + + +def _build_audio(narrations: list[str], music: str | None, out: str): + """Concat narration tracks, then duck music under them via the audio_mixer tool (ffmpeg).""" + from tools.tool_registry import registry + tmp = Path(out).with_suffix(".nar.wav") + if narrations: + lst = Path(out).with_suffix(".list.txt") + lst.write_text("".join(f"file '{p}'\n" for p in narrations)) + subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst), str(tmp)], + capture_output=True, check=True) + mixer = registry.get("audio_mixer") + if music and narrations and mixer is not None: + r = mixer.execute({"operation": "duck", + "tracks": [{"path": str(tmp), "role": "speech"}, {"path": music, "role": "music"}], + "ducking": {"enabled": True, "music_volume_during_speech": 0.15}, + "output_path": out}) + if r.success: + return + # fallback: narration-only (or music-only) + src = str(tmp) if narrations else music + subprocess.run(["ffmpeg", "-y", "-i", src, out], capture_output=True, check=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--prompt", required=True) + ap.add_argument("--out", required=True) + args = ap.parse_args() + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + run(args.prompt, args.out) + + +if __name__ == "__main__": + main() diff --git a/tests/tools/test_agent_runtime.py b/tests/tools/test_agent_runtime.py new file mode 100644 index 000000000..a832e486d --- /dev/null +++ b/tests/tools/test_agent_runtime.py @@ -0,0 +1,78 @@ +"""Offline test for the autonomous agent runtime (no LLM key, no GPU, no provider keys). + +Uses a STUB LLM (emits valid brief/script/scene_plan artifacts) + AGENT_RUNTIME_FAKE_ASSETS=1 +(ffmpeg color/sine fixtures) so the full pipeline plumbing — LLM planning -> schema validation -> +checkpoints -> per-scene assets -> audio mix -> ffmpeg compose -> final mp4 — is exercised +deterministically. Proves the harness works end-to-end before any cloud LLM / provider is attached. + +Run: pytest tests/tools/test_agent_runtime.py -q (needs ffmpeg on PATH) +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] + +pytest.importorskip("jsonschema") +if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: + pytest.skip("ffmpeg/ffprobe not available", allow_module_level=True) + + +def _stub(system: str, user: str) -> str: + s = system[:120].lower() # match the prompt PREFIX, before appended skill text + if "scene director" in s: + return json.dumps({"version": "1.0", "scenes": [ + {"id": "sc1", "type": "generated", "description": "a red fox walking into a snowy forest, cinematic", + "start_seconds": 0, "end_seconds": 5, "script_section_id": "s1"}, + {"id": "sc2", "type": "generated", "description": "fox pauses, soft winter light, slow motion", + "start_seconds": 5, "end_seconds": 10, "script_section_id": "s2"}]}) + if "script director" in s: + return json.dumps({"version": "1.0", "title": "Snowy Fox", "total_duration_seconds": 10, + "sections": [ + {"id": "s1", "text": "A red fox steps into a snowy forest.", + "start_seconds": 0, "end_seconds": 5}, + {"id": "s2", "text": "It pauses as soft winter light filters through.", + "start_seconds": 5, "end_seconds": 10}]}) + if "idea director" in s: + return json.dumps({"version": "1.0", "title": "Snowy Fox", "hook": "A fox in the snow.", + "key_points": ["winter", "forest", "cinematic"], "tone": "calm", + "style": "cinematic", "target_platform": "youtube", + "target_duration_seconds": 10}) + return "{}" + + +def test_agent_runtime_end_to_end(tmp_path, monkeypatch): + monkeypatch.setenv("AGENT_RUNTIME_FAKE_ASSETS", "1") + monkeypatch.setenv("AGENT_CLIP_WIDTH", "320") # tiny for speed + monkeypatch.setenv("AGENT_CLIP_HEIGHT", "240") + import importlib + import services.agent_runtime.run as run_mod + importlib.reload(run_mod) + from services.agent_runtime.llm import LLM + + out = tmp_path / "final.mp4" + run_mod.run("a red fox in a snowy forest", str(out), llm=LLM(stub=_stub), + project_dir=tmp_path / "proj") + + assert out.exists() and out.stat().st_size > 0 + probe = subprocess.run(["ffprobe", "-v", "error", "-show_entries", + "stream=codec_name,width,height", "-of", "default=noprint_wrappers=1", str(out)], + capture_output=True, text=True) + assert "codec_name=h264" in probe.stdout, probe.stdout + probe.stderr + # checkpoints written for the planning + asset + edit stages + cks = list((tmp_path / "proj" / "pipeline").rglob("checkpoint_*.json")) + names = {c.stem for c in cks} + assert {"checkpoint_idea", "checkpoint_script", "checkpoint_scene_plan", + "checkpoint_assets", "checkpoint_edit"} <= names, names + + +def test_llm_json_extraction(): + from services.agent_runtime.llm import LLM + llm = LLM(stub=lambda s, u: 'prefix ```json\n{"a": 1, "b": {"c": 2}}\n``` suffix') + assert llm.complete_json("sys", "user") == {"a": 1, "b": {"c": 2}} From 0bb32f40255189b364ceab4108c9baf9730fd59a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 04:40:55 +0000 Subject: [PATCH 02/28] fix(agent-runtime): resolve ffmpeg via imageio-ffmpeg on PATH (Macs have no system ffmpeg) _prepare_ffmpeg symlinks the bundled imageio-ffmpeg binary as 'ffmpeg' on PATH so video_compose/ audio_mixer subprocesses work without a brew install. Verified end-to-end on the head Mac (stub LLM + fake assets -> real mp4). Offline tests still pass. Co-authored-by: FluffyAIcode --- services/agent_runtime/run.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py index 668de16dd..b3aa2dcdb 100644 --- a/services/agent_runtime/run.py +++ b/services/agent_runtime/run.py @@ -48,6 +48,33 @@ def log(msg: str): print(f"[agent] {msg}", flush=True) +def _prepare_ffmpeg(): + """Ensure `ffmpeg`/`ffprobe` resolve on PATH for all tool subprocesses. + + Macs in this deployment have no system ffmpeg — the cluster uses imageio-ffmpeg's bundled + binary. Symlink it as `ffmpeg` (and `ffprobe` if imageio-ffmpeg ships one) into a dir on PATH + so video_compose/audio_mixer (which call bare `ffmpeg`) work without a brew install.""" + import shutil + if shutil.which("ffmpeg") and shutil.which("ffprobe"): + return + try: + import imageio_ffmpeg + exe = imageio_ffmpeg.get_ffmpeg_exe() + except Exception: # noqa: BLE001 + return + bind = Path(os.environ.get("TMPDIR", "/tmp")) / "agent_ffmpeg_bin" + bind.mkdir(parents=True, exist_ok=True) + link = bind / "ffmpeg" # imageio-ffmpeg bundles ffmpeg only (not ffprobe) + if not link.exists(): + try: + os.symlink(exe, link) + except OSError: + pass + os.environ["PATH"] = f"{bind}{os.pathsep}{os.environ.get('PATH', '')}" + os.environ.setdefault("IMAGEIO_FFMPEG_EXE", exe) + os.environ.setdefault("FFMPEG_BINARY", exe) + + def _skill(path: str) -> str: """Best-effort director-skill text for LLM context (truncated). Intelligence lives in skills.""" for cand in (REPO / "skills" / f"{path}.md", REPO / f"{path}.md"): @@ -152,6 +179,7 @@ def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None from schemas.artifacts import validate_artifact from tools.tool_registry import registry + _prepare_ffmpeg() registry.discover() llm = llm or LLM() log(f"LLM provider={llm.provider} model={llm.model or '(default)'} fake_assets={FAKE}") From 083ba8a51f352e1945ec50dd0efc7ccd79466c44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 10:53:07 +0000 Subject: [PATCH 03/28] feat(worker): add HunyuanVideo backend (T2V + I2V) as a higher-quality provider option diffusers HunyuanVideoPipeline / HunyuanVideoImageToVideoPipeline; 13B, native 720p ~5s clips. --backend hunyuan, ops framework,i2v; HUNYUAN_OFFLOAD (default on) for VRAM. For an A/B vs WAN to decide whether Hunyuan becomes the default quality layer. Co-authored-by: FluffyAIcode --- services/distributed_wan/grpc_worker.py | 97 ++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/services/distributed_wan/grpc_worker.py b/services/distributed_wan/grpc_worker.py index b76f41797..64877c6d0 100644 --- a/services/distributed_wan/grpc_worker.py +++ b/services/distributed_wan/grpc_worker.py @@ -187,6 +187,99 @@ def refine(self, req, q): return _to_uint8(out.frames[0]) +# --------------------------------------------------------------------------- # +# HunyuanVideo backend (diffusers) — a higher-quality open-source provider (ADR 0017). +# T2V via HunyuanVideoPipeline, I2V via HunyuanVideoImageToVideoPipeline. 13B; native 720p, +# ~5s clips. Heavy: CPU offload on by default (HUNYUAN_OFFLOAD=1). CUDA/vast only (no MLX). +# --------------------------------------------------------------------------- # +class HunyuanBackend: + MODEL = os.environ.get("HUNYUAN_MODEL", "hunyuanvideo-community/HunyuanVideo") + + def __init__(self, ops): + self.ops = ops or ["framework"] + self._lock = threading.Lock() + self.pipe = None + self._i2v = None + self.offload = os.environ.get("HUNYUAN_OFFLOAD", "1").lower() in ("1", "true", "yes") + + def _load_t2v(self): + if self.pipe is not None: + return + import torch + from diffusers import HunyuanVideoPipeline, HunyuanVideoTransformer3DModel + torch.set_grad_enabled(False) + tr = HunyuanVideoTransformer3DModel.from_pretrained(self.MODEL, subfolder="transformer", + torch_dtype=torch.bfloat16) + p = HunyuanVideoPipeline.from_pretrained(self.MODEL, transformer=tr, torch_dtype=torch.float16) + p.vae.enable_tiling() + p.enable_model_cpu_offload() if self.offload else p.to("cuda") + self.pipe = p + + def _load_i2v(self): + if self._i2v is not None: + return + import torch + from diffusers import HunyuanVideoImageToVideoPipeline + p = HunyuanVideoImageToVideoPipeline.from_pretrained(self.MODEL, torch_dtype=torch.float16) + p.vae.enable_tiling() + p.enable_model_cpu_offload() if self.offload else p.to("cuda") + self._i2v = p + + def health(self): + import torch + return pb.HealthReply(device="cuda" if torch.cuda.is_available() else "cpu", + backend="hunyuan-diffusers", model=self.MODEL, ops=self.ops, ready=False, + note=torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu", + relative_speed=float(os.environ.get("HUNYUAN_RELATIVE_SPEED", "0.8"))) + + def _cb(self, q, total): + def cb(pipe, i, t, kw): + try: + q.put_nowait(("p", (i + 1) / max(total, 1))) + except queue.Full: + pass + return kw + return cb + + @staticmethod + def _snap(req): + w = max(16, (req.width or 1280) // 16 * 16) + h = max(16, (req.height or 720) // 16 * 16) + nf = req.num_frames or 61 + if nf % 4 != 1: # Hunyuan needs num_frames == 4k+1 + nf = (nf // 4) * 4 + 1 + return w, h, nf + + def framework(self, req, q): + import torch + if getattr(req, "init_image", b"") and "i2v" in self.ops: + return self.i2v(req, q) + self._load_t2v() + w, h, nf = self._snap(req) + with self._lock: + g = torch.Generator("cpu").manual_seed(req.seed or 42) + out = self.pipe(prompt=req.prompt, height=h, width=w, num_frames=nf, + num_inference_steps=req.steps or 30, generator=g, + callback_on_step_end=self._cb(q, req.steps or 30)) + return _to_uint8(out.frames[0]) + + def i2v(self, req, q): + import torch + from PIL import Image + self._load_i2v() + w, h, nf = self._snap(req) + img = Image.open(io.BytesIO(req.init_image)).convert("RGB").resize((w, h)) + with self._lock: + g = torch.Generator("cpu").manual_seed(req.seed or 42) + out = self._i2v(image=img, prompt=req.prompt, height=h, width=w, num_frames=nf, + num_inference_steps=req.steps or 30, generator=g, + callback_on_step_end=self._cb(q, req.steps or 30)) + return _to_uint8(out.frames[0]) + + def refine(self, req, q): + raise RuntimeError("hunyuan backend has no refine op; use framework/i2v") + + # --------------------------------------------------------------------------- # # MLX backend (wraps mlx-video) — Mac mini; OWNER-RUN (untested in CI; no Mac here) # --------------------------------------------------------------------------- # @@ -425,7 +518,7 @@ def RefineTile(self, request, context): def main(): ap = argparse.ArgumentParser() - ap.add_argument("--backend", choices=["cuda", "mlx", "test"], default="cuda") + ap.add_argument("--backend", choices=["cuda", "mlx", "test", "hunyuan"], default="cuda") ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--port", type=int, default=50051) ap.add_argument("--preload", action="store_true") @@ -441,6 +534,8 @@ def main(): backend.load() elif args.backend == "test": backend = TestBackend() + elif args.backend == "hunyuan": + backend = HunyuanBackend([o.strip() for o in (args.ops or "framework,i2v").split(",") if o.strip()]) else: backend = MlxBackend(args.mlx_model_dir, [o.strip() for o in args.mlx_ops.split(",") if o.strip()]) From 9a539c1ef4215583310465d31b18c7a74d732c56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 11:21:56 +0000 Subject: [PATCH 04/28] =?UTF-8?q?docs:=20ADR=200017=20+=20loop-log=2042=20?= =?UTF-8?q?=E2=80=94=20A/B=20HunyuanVideo=20vs=20WAN;=20adopt=20Hunyuan=20?= =?UTF-8?q?as=20quality=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real same-prompt 720p A/B on vast: WAN quality=high = unusable abstract blobs (bad 1.3B seed); HunyuanVideo = coherent photorealistic cinematic clip. Verdict: Hunyuan = default quality provider. Caveats: ~8min/2s@720p, VRAM-heavy, prompt-adherence/long-form/license noted. Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 17 +++++++ .../adr/0017-hunyuanvideo-quality-provider.md | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docs/adr/0017-hunyuanvideo-quality-provider.md diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index 6c2631e78..9892ec50d 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1148,6 +1148,23 @@ test on agent.kakeya.ai pending the key. --- +## Iteration 42 — A/B: HunyuanVideo vs WAN (real frames) → adopt Hunyuan as quality layer (ADR 0017) + +Same prompt (border collie, snowy forest, cinematic), both 1280×720 ~2s on vast Blackwell. +- **WAN** (`quality=high`: 1.3B T2V seed → I2V-14B): frame = chaotic abstract blobs, **no recognizable + dog/forest** — unusable. Root cause: garbage low-res 1.3B seed amplified by I2V. +- **Hunyuan** (`hunyuanvideo-community/HunyuanVideo`, 45f/30steps, no offload): frame = **coherent, + photorealistic** dog in a snowy forest, cinematic DoF — production-looking. + +**Verdict (frame-level visual judgment): adopt HunyuanVideo as the default quality layer.** Night-and- +day. Caveats: ~8 min / 2s clip @720p (~13–16 s/step), VRAM-heavy (evicted the 85 G WAN worker to avoid +OOM), prompt adherence imperfect (rendered a Shiba-type dog), long-form still needs Hunyuan-I2V, +license stricter (Tencent community). Added `HunyuanBackend` to `grpc_worker.py` (`--backend hunyuan`, +ops framework/i2v). Next: register `hunyuan_video` as a provider for `video_selector`/agent; resolve +WAN-14B vs Hunyuan VRAM (load-on-demand); wire Hunyuan-I2V for continuity. + +--- + ## Open follow-ups (next iterations) - **Phase 2b — native gRPC transport.** Add an optional `kakeya` Python SDK transport for the bounded-memory long-context path (W3), behind the same tool, once the proto diff --git a/docs/adr/0017-hunyuanvideo-quality-provider.md b/docs/adr/0017-hunyuanvideo-quality-provider.md new file mode 100644 index 000000000..0555a40a5 --- /dev/null +++ b/docs/adr/0017-hunyuanvideo-quality-provider.md @@ -0,0 +1,48 @@ +# ADR 0017 — HunyuanVideo as the default quality video provider (A/B vs WAN) + +**Status:** Accepted (decision: adopt Hunyuan as the quality layer). Productization pending. +**Date:** 2026-06-23 +**Related:** ADR 0015/0016 (quality/duration, agent runtime), the "four open-source models as +providers" design. + +## 1. Question + +Does replacing/augmenting WAN 2.1 with **HunyuanVideo** (13B) improve video quality enough to make it +the default quality layer? Decided by a **real A/B** on the same prompt, judged on actual frames. + +## 2. Setup + +- vast RTX PRO 6000 Blackwell (97 G). Same prompt: *"a border collie dog running through a snowy + forest, cinematic, soft winter light."* Both 1280×720, ~2 s. +- **WAN** = current `quality=high` path (1.3B T2V seed → I2V-14B @720p), via the public gateway. +- **Hunyuan** = `hunyuanvideo-community/HunyuanVideo` (diffusers `HunyuanVideoPipeline`), 45 frames, + 30 steps, no offload. New `HunyuanBackend` added to `grpc_worker.py` (`--backend hunyuan`, ops + framework/i2v). + +## 3. Result (frame-level visual judgment) + +- **WAN**: a chaotic, abstract blob field — **no recognizable dog or forest**, heavy SR artifacts. + Effectively unusable. (Root cause: the 1.3B low-res T2V *seed* is garbage; I2V amplifies it.) +- **Hunyuan**: a **coherent, photorealistic** dog walking through a snowy forest, cinematic shallow + depth of field, realistic snow + blurred trunks. **Clearly production-looking** for a single clip. + +**Verdict: adopt HunyuanVideo as the default quality layer.** The gap is night-and-day. + +## 4. Honest caveats + +- **Speed/VRAM:** Hunyuan ~13–16 s/step × 30 + VAE decode ≈ **~8 min for a ~2 s 720p clip**, and it + needs the GPU largely to itself (~49 GB active; the resident 85 GB WAN worker had to be evicted to + avoid OOM). Slower + heavier than WAN. +- **Prompt adherence:** it rendered a Shiba/Akita-type dog, not specifically a *border collie* — scene + quality is excellent but fine-grained subject control needs prompt work (Layer-3 skill). +- **Long-form:** multi-shot >5 s still needs `HunyuanVideo-I2V` chunking (drift risk remains). +- **License:** Tencent community license (region/MAU limits) — stricter than WAN's Apache-2.0. + +## 5. Decision / next + +1. **Hunyuan = default quality provider** (single-clip T2V hero path); WAN demoted to fast/draft + + fallback. Both stay (the four-model provider design). +2. **Productize:** register `hunyuan_video` as a `video_generation` provider so the agent/ + `video_selector` routes to it for quality; resolve the VRAM tradeoff (can't keep WAN-14B (85 G) + + Hunyuan resident — load the quality model on demand or dedicate the box to Hunyuan). +3. Wire HunyuanVideo-I2V for long-form continuity; improve prompting for subject fidelity. From 6f0990897a53381cee8d1d4f86b539738ce7dc0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 13:08:44 +0000 Subject: [PATCH 05/28] agent runtime: add LLM prompt-director step (activate Layer-3 video skills) Per ADR 0016, enrich each scene into a rich native-T2V prompt before generation, using the provider's advertised agent_skills (ai-video-gen/ltx2) as guidance, and persist directed prompts to assets/director_prompts.json. Extends offline test. Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 26 ++++++ docs/adr/0016-autonomous-agent-runtime.md | 20 +++-- services/agent_runtime/run.py | 84 +++++++++++++++++++- tests/tools/test_agent_runtime.py | 10 +++ 4 files changed, 132 insertions(+), 8 deletions(-) diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index 9892ec50d..15aced870 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1165,6 +1165,32 @@ WAN-14B vs Hunyuan VRAM (load-on-demand); wire Hunyuan-I2V for continuity. --- +## Iteration 43 — agent runtime: LLM "prompt director" step (ADR 0016, per user) + +The user clarified the "LLM prompt director" is **already in OpenMontage's architecture** (the +Layer-3 video skills) and asked to keep executing the agent-runtime integration per ADR 0016 — +i.e. *activate* that existing knowledge, not invent a new step. Implemented in +`services/agent_runtime/run.py`: + +- **`_resolve_video_skill(registry)`** — reads the `agent_skills` advertised by the video provider + `video_selector` routes to (e.g. `ai-video-gen`/`ltx2`) from `.agents/skills` or `.claude/skills`, + and feeds that prompting guidance to the LLM. So per-scene prompts are written the way the chosen + model wants, sourced from the skills OpenMontage already ships. +- **`direct_video_prompt(llm, scene, brief, skill)`** — rewrites each scene description into ONE + rich native-T2V prompt (subject+action+setting+lighting+camera+style, ~40–70 words, positive + phrasing). This is the cross-attention insight from ADR 0015: **enrich the text condition and + hand a strong prompt to native T2V** instead of refining a weak low-res seed via I2V. Falls back + to the raw scene on any LLM error. Needs **no video GPU** — runs as soon as the LLM is up. +- Directed prompts are persisted to `assets/director_prompts.json` for transparency; the assets + loop now generates from the **directed** prompt (not the bare scene description). + +**Validation (offline, no keys/GPU):** `tests/tools/test_agent_runtime.py` extended — the stub LLM +handles the "video prompt director" system prompt and the test asserts each scene received a +directed prompt (`startswith "directed cinematic shot, "`, `!= raw`) via `director_prompts.json`. +`2 passed`. Still blocked on a live head LLM endpoint (Kakeya/Gemma) for the real `mode=agent` run. + +--- + ## Open follow-ups (next iterations) - **Phase 2b — native gRPC transport.** Add an optional `kakeya` Python SDK transport for the bounded-memory long-context path (W3), behind the same tool, once the proto diff --git a/docs/adr/0016-autonomous-agent-runtime.md b/docs/adr/0016-autonomous-agent-runtime.md index df65f7bff..32e56d776 100644 --- a/docs/adr/0016-autonomous-agent-runtime.md +++ b/docs/adr/0016-autonomous-agent-runtime.md @@ -31,9 +31,18 @@ New module `services/agent_runtime/` exposed as `AGENT_RUNTIME_CMD` - LLM produces **brief → script → scene_plan** (each schema-validated via `schemas.artifacts` and checkpointed via `lib/checkpoint`, exactly like the governed pipeline; director-skill text is loaded as LLM context — "intelligence in the skills"). + - **prompt director** (`direct_video_prompt`): before each clip, the LLM rewrites the scene + description into ONE rich, model-appropriate **native-T2V** prompt (subject+action+setting+ + lighting+camera+style, ~40–70 words, positive phrasing). Guidance is the **Layer-3 skill the + video provider advertises** (`_resolve_video_skill` reads the provider's `agent_skills`, e.g. + `ai-video-gen`/`ltx2`, from `.agents/skills` or `.claude/skills`) — i.e. it activates the prompt- + director knowledge OpenMontage already ships rather than inventing a new one. This is the + cross-attention insight from ADR 0015: enrich the text condition and hand a strong prompt to T2V, + instead of refining a weak low-res seed via I2V. Directed prompts are persisted to + `assets/director_prompts.json` for transparency. Needs no video GPU — runs as soon as the LLM is up. - assets: per-scene video via **`video_selector`** (auto-routes to the BEST available provider — - premium Seedance/Kling/Veo if configured, else the local WAN draft cluster), narration via - **`tts_selector`**, one music track via **`music_gen`** (skipped when unconfigured). + premium Seedance/Kling/Veo if configured, else the local WAN draft cluster) using the **directed** + prompt, narration via **`tts_selector`**, one music track via **`music_gen`** (skipped when unconfigured). - deterministic **edit_decisions** → **`audio_mixer`** + **`video_compose`** (ffmpeg) → final mp4. - `AGENT_RUNTIME_FAKE_ASSETS=1` swaps providers for ffmpeg fixtures so the harness is CI-testable with no keys/GPU. @@ -57,9 +66,10 @@ agent uses a cloud LLM (light CPU/network) — it does **not** steal GPU from vi ## 5. Validation `tests/tools/test_agent_runtime.py` (offline, no keys/GPU): stub LLM emits valid brief/script/ -scene_plan; `AGENT_RUNTIME_FAKE_ASSETS=1` → full plumbing (plan → validate → checkpoint → per-scene -assets → audio mix → ffmpeg compose) → **valid h264 mp4** + checkpoints for idea/script/scene_plan/ -assets/edit. JSON-extraction unit test. 2 pass (23 with gateway+pipeline suites). +scene_plan; `AGENT_RUNTIME_FAKE_ASSETS=1` → full plumbing (plan → validate → **prompt director** → +checkpoint → per-scene assets → audio mix → ffmpeg compose) → **valid h264 mp4** + checkpoints for +idea/script/scene_plan/assets/edit. The test asserts each scene received a **directed** prompt +(enriched, `!= raw` description) via `director_prompts.json`. JSON-extraction unit test. 2 pass. ## 6. Next diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py index b3aa2dcdb..e2d3dc6cf 100644 --- a/services/agent_runtime/run.py +++ b/services/agent_runtime/run.py @@ -85,6 +85,75 @@ def _skill(path: str) -> str: return "" +def _skill_named(name: str) -> str: + """Read a named Layer-3 skill (SKILL.md) from the skills trees (truncated).""" + for base in (REPO / ".agents" / "skills", REPO / ".claude" / "skills"): + cand = base / name / "SKILL.md" + try: + return cand.read_text() + except OSError: + continue + return "" + + +def _resolve_video_skill(registry) -> str: + """Layer-3 prompting guidance for the active video provider. + + OpenMontage already ships the "prompt director" knowledge as Layer-3 skills (ai-video-gen, + ltx2, seedance-2-0, ...). The agent reads the skill(s) advertised by the video provider that + `video_selector` routes to and feeds them to the LLM so per-scene prompts are written the way + that model wants — instead of passing a bare scene description to the generator.""" + names: list[str] = [] + sel = registry.get("video_selector") + if sel is not None: + try: + info = sel.get_info() + # If the selector can name the provider it would route to, prefer that provider's skill. + chosen = info.get("selected_tool") or info.get("provider") + if chosen: + prov = registry.get(chosen) + if prov is not None: + names = list(getattr(prov, "agent_skills", []) or []) + if not names: + names = list(info.get("agent_skills", []) or []) + except Exception: # noqa: BLE001 + pass + if not names: + names = ["ai-video-gen"] + txt = "" + for n in names[:2]: + s = _skill_named(n) + if s: + txt += f"\n# Provider guidance: {n}\n{s[:4500]}\n" + return txt[:8000] + + +def direct_video_prompt(llm: LLM, scene_desc: str, brief: dict, skill_text: str) -> str: + """LLM "prompt director": rewrite a scene into ONE rich, model-appropriate T2V prompt. + + Native T2V conditions generation via cross-attention over the text embeddings, so a longer, + concrete, positive prompt (subject+action+setting+lighting+camera+style) yields a far stronger + result than a bare scene description. Guidance comes from the provider's Layer-3 skill.""" + if not scene_desc: + return scene_desc + sys_p = ( + "You are OpenMontage's video prompt director. Rewrite the scene into ONE rich, concrete " + "text-to-video prompt for a native text-to-video model. Include subject, action, setting, " + "lighting, camera framing/movement, and style. Be vivid and specific, ~40-70 words, fully " + "positive phrasing (describe what to SHOW, never what to avoid), no contradictions. " + "Output ONLY the prompt text — no quotes, no JSON, no preamble." + skill_text + ) + user = (f"Scene: {scene_desc}\nOverall tone: {brief.get('tone', '')}\n" + f"Style: {brief.get('style', '')}\nTitle: {brief.get('title', '')}") + try: + out = llm.complete(sys_p, user) + except Exception as exc: # noqa: BLE001 + log(f"prompt-director fallback (raw scene): {exc}") + return scene_desc + out = " ".join((out or "").strip().strip('"').strip("`").split()) + return out[:600] or scene_desc + + # --------------------------------------------------------------------------- # # asset helpers (real providers via the registry, or ffmpeg fixtures for tests) # --------------------------------------------------------------------------- # @@ -212,12 +281,17 @@ def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None # 4) assets: per-scene video (video_selector -> best provider) + narration; one music track sect_by_id = {s["id"]: s for s in script.get("sections", [])} - asset_list, cuts = [], [] + vskill = _resolve_video_skill(registry) + log(f"prompt-director: loaded provider guidance ({len(vskill)} chars)") + asset_list, cuts, director_prompts = [], [], [] for i, sc in enumerate(scenes): dur = max(2.0, float(sc.get("end_seconds", 0)) - float(sc.get("start_seconds", 0)) or 5.0) vid = str(assets / f"scene_{i}.mp4") - log(f"scene {i}: generating clip ({dur:.0f}s) — {sc.get('description','')[:60]}") - vid = generate_clip(registry, sc.get("description", prompt), vid, dur, i) + raw = sc.get("description", prompt) + vprompt = direct_video_prompt(llm, raw, brief, vskill) + director_prompts.append({"scene_id": sc.get("id", f"sc{i}"), "raw": raw, "prompt": vprompt}) + log(f"scene {i}: directed prompt — {vprompt[:80]}") + vid = generate_clip(registry, vprompt, vid, dur, i) asset_list.append({"id": f"v{i}", "type": "video", "path": vid, "source_tool": "video_selector", "scene_id": sc.get("id", f"sc{i}")}) cuts.append({"id": f"cut{i}", "source": vid, "in_seconds": 0.0, "out_seconds": dur, "speed": 1.0}) @@ -237,6 +311,10 @@ def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None "scene_id": scenes[0].get("id", "sc0") if scenes else "sc0"}) asset_manifest = {"version": "1.0", "assets": asset_list} validate_artifact("asset_manifest", asset_manifest); ck("assets", {"asset_manifest": asset_manifest}) + try: + (assets / "director_prompts.json").write_text(json.dumps(director_prompts, indent=2)) + except OSError: + pass # 5) edit_decisions (deterministic: clips in order, ffmpeg runtime) narrations = [a["path"] for a in asset_list if a["type"] == "narration"] diff --git a/tests/tools/test_agent_runtime.py b/tests/tools/test_agent_runtime.py index a832e486d..f44ff019a 100644 --- a/tests/tools/test_agent_runtime.py +++ b/tests/tools/test_agent_runtime.py @@ -26,6 +26,10 @@ def _stub(system: str, user: str) -> str: s = system[:120].lower() # match the prompt PREFIX, before appended skill text + if "video prompt director" in s: + # echo the raw scene so the test can verify the DIRECTED prompt is what reaches the generator + scene = user.split("\n", 1)[0].removeprefix("Scene: ").strip() + return f"directed cinematic shot, {scene}, golden hour rim light, slow dolly-in, 35mm film" if "scene director" in s: return json.dumps({"version": "1.0", "scenes": [ {"id": "sc1", "type": "generated", "description": "a red fox walking into a snowy forest, cinematic", @@ -70,6 +74,12 @@ def test_agent_runtime_end_to_end(tmp_path, monkeypatch): names = {c.stem for c in cks} assert {"checkpoint_idea", "checkpoint_script", "checkpoint_scene_plan", "checkpoint_assets", "checkpoint_edit"} <= names, names + # prompt-director step ran: each scene got a DIRECTED prompt (enriched, != raw description) + dp = json.loads((tmp_path / "proj" / "assets" / "director_prompts.json").read_text()) + assert len(dp) == 2, dp + for entry in dp: + assert entry["prompt"].startswith("directed cinematic shot, "), entry + assert entry["prompt"] != entry["raw"], entry def test_llm_json_extraction(): From d5aaf0f0822c05a8c3b532f9a59e1e88eb8bbf97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 13:44:40 +0000 Subject: [PATCH 06/28] agent runtime: add --check-llm endpoint smoke test One tiny LLM round-trip to verify the reasoner (head Kakeya/Gemma gRPC runtime via OpenAI HTTP shim) before committing to a full pipeline run. Exit 0/1/2 = ok/error/ no-endpoint. Docs updated with the live mode=agent verification recipe. Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 11 ++++++- docs/adr/0016-autonomous-agent-runtime.md | 9 ++++-- services/agent_runtime/run.py | 32 ++++++++++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index 15aced870..cebff951a 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1187,7 +1187,16 @@ i.e. *activate* that existing knowledge, not invent a new step. Implemented in **Validation (offline, no keys/GPU):** `tests/tools/test_agent_runtime.py` extended — the stub LLM handles the "video prompt director" system prompt and the test asserts each scene received a directed prompt (`startswith "directed cinematic shot, "`, `!= raw`) via `director_prompts.json`. -`2 passed`. Still blocked on a live head LLM endpoint (Kakeya/Gemma) for the real `mode=agent` run. +`2 passed`. + +**LLM endpoint smoke test (`--check-llm`).** The head now runs the Kakeya **Gemma 26B** runtime +(gRPC :51051, kept alive via `caffeinate`) next to the gateway (:8088, `/healthz agent_runtime:true`). +Added `python -m services.agent_runtime.run --check-llm`: one tiny LLM round-trip prints +provider/model + latency, exits 0 (OK) / 1 (endpoint error) / 2 (no endpoint, provider=stub) — so the +reasoner is verified in ~seconds before committing to a 20–30 min pipeline run. Cloud agent cannot +submit `mode=agent` directly (gateway enforces `X-API-Key`); the live planning+director run is driven +by the key holder. The agent runtime reaches the runtime via the OpenAI-compatible HTTP shim +(`AGENT_LLM=kakeya` + `KAKEYA_ENDPOINT`); the gRPC `RuntimeService` is token-id level, not text I/O. --- diff --git a/docs/adr/0016-autonomous-agent-runtime.md b/docs/adr/0016-autonomous-agent-runtime.md index 32e56d776..2b7c9bd13 100644 --- a/docs/adr/0016-autonomous-agent-runtime.md +++ b/docs/adr/0016-autonomous-agent-runtime.md @@ -73,8 +73,13 @@ idea/script/scene_plan/assets/edit. The test asserts each scene received a **dir ## 6. Next -- Deploy: set `AGENT_RUNTIME_CMD=" -m services.agent_runtime.run"` + a cloud LLM key on the - head; live-test `mode=agent` via `agent.kakeya.ai`. +- Deploy: set `AGENT_RUNTIME_CMD=" -m services.agent_runtime.run"` + an LLM endpoint on the + head; live-test `mode=agent` via `agent.kakeya.ai`. **LLM endpoint smoke test:** run + `python -m services.agent_runtime.run --check-llm` first — one tiny round-trip prints + provider/model + latency and exits non-zero on failure, so the reasoner is verified before a full + (slow) pipeline run. With the head's Kakeya/Gemma runtime, point `AGENT_LLM=kakeya` + + `KAKEYA_ENDPOINT` at the OpenAI-compatible HTTP shim (the stable text surface; the gRPC + `RuntimeService` is token-id level). - Configure premium video provider for production-grade clips (the quality answer from the draft-vs-premium discussion). - Grow toward the governed pipeline (proposal/approval/reviewer, more director skills) and more diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py index e2d3dc6cf..4fd2a0c01 100644 --- a/services/agent_runtime/run.py +++ b/services/agent_runtime/run.py @@ -370,11 +370,39 @@ def _build_audio(narrations: list[str], music: str | None, out: str): subprocess.run(["ffmpeg", "-y", "-i", src, out], capture_output=True, check=True) +def check_llm() -> int: + """Fast LLM-endpoint smoke test: one tiny round-trip. Verify the reasoner is reachable + BEFORE committing to a full (slow) pipeline run. Returns a process exit code.""" + import time + llm = LLM() + log(f"LLM provider={llm.provider} model={llm.model or '(default)'}") + if llm.provider == "stub": + log("LLM check: provider=stub — no real endpoint configured " + "(set AGENT_LLM + KAKEYA_ENDPOINT/ANTHROPIC_API_KEY/OPENAI_API_KEY).") + return 2 + t0 = time.time() + try: + out = llm.complete("You are a health check. Reply with exactly: OK", + "Reply with the single word OK.", max_tokens=16) + except Exception as exc: # noqa: BLE001 + log(f"LLM check FAILED ({type(exc).__name__}): {exc}") + return 1 + dt = time.time() - t0 + log(f"LLM check OK in {dt:.1f}s — reply: {(out or '').strip()[:80]!r}") + return 0 + + def main(): ap = argparse.ArgumentParser() - ap.add_argument("--prompt", required=True) - ap.add_argument("--out", required=True) + ap.add_argument("--prompt", help="brief/idea to drive the pipeline") + ap.add_argument("--out", help="output mp4 path") + ap.add_argument("--check-llm", action="store_true", + help="only verify the LLM endpoint round-trips, then exit (no GPU/pipeline)") args = ap.parse_args() + if args.check_llm: + raise SystemExit(check_llm()) + if not args.prompt or not args.out: + ap.error("--prompt and --out are required (unless --check-llm)") Path(args.out).parent.mkdir(parents=True, exist_ok=True) run(args.prompt, args.out) From c46922fa365395480c445f3f5a6ca33371290468 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 14:07:16 +0000 Subject: [PATCH 07/28] agent runtime: add kakeya_grpc provider (direct Kakeya gRPC RuntimeService) Fold the head's working transport into the repo: AGENT_LLM=kakeya_grpc talks token-id level to the user-run Kakeya engine (Gemma 26B @ :51051) via its gRPC SDK. External dependency contract (KAKEYA_REPO required; kakeya/transformers imported lazily) so grpc/ transformers never become core deps. Clear RuntimeError when KAKEYA_REPO unset; offline guard test. Verified live by owner (--check-llm -> OK). Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 15 ++++- docs/adr/0016-autonomous-agent-runtime.md | 26 ++++++--- services/agent_runtime/llm.py | 59 +++++++++++++++++++- services/agent_runtime/run.py | 4 +- tests/tools/test_agent_runtime.py | 12 ++++ 5 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index cebff951a..a9f247c62 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1195,8 +1195,19 @@ Added `python -m services.agent_runtime.run --check-llm`: one tiny LLM round-tri provider/model + latency, exits 0 (OK) / 1 (endpoint error) / 2 (no endpoint, provider=stub) — so the reasoner is verified in ~seconds before committing to a 20–30 min pipeline run. Cloud agent cannot submit `mode=agent` directly (gateway enforces `X-API-Key`); the live planning+director run is driven -by the key holder. The agent runtime reaches the runtime via the OpenAI-compatible HTTP shim -(`AGENT_LLM=kakeya` + `KAKEYA_ENDPOINT`); the gRPC `RuntimeService` is token-id level, not text I/O. +by the key holder. + +**Live transport = `kakeya_grpc` (direct gRPC, not the HTTP shim).** Folded the head's working +provider into the repo: `AGENT_LLM=kakeya_grpc` calls the Kakeya engine's native gRPC `RuntimeService` +at token-id level (the stable surface — the OpenAI HTTP shim is deprecated/single-session). It is an +**external dependency contract** — `KAKEYA_REPO` must point at a Kakeya checkout (top-level `kakeya` +pkg + `sdks/python`); `kakeya`/`transformers` import **lazily** only when selected, so grpc/transformers +never enter OpenMontage's core deps (D5/D7). Flow: `apply_chat_template(enable_thinking=False)` → +`Client.create_session(eos_token_ids)` → `append` → `generate(max_tokens)` → `decode`. Env: +`KAKEYA_GRPC_ADDRESS=127.0.0.1:51051`, `KAKEYA_TOKENIZER_ID` (→ `KAKEYA_VERIFIER_ID` fallback). Head +deps in `~/.venv-distwan`: grpcio 1.81, transformers 5.12, mlx-lm 0.31. **Owner confirmed live:** +`--check-llm` → `provider=kakeya_grpc model=kakeya-local` → `OK`. Offline guard test asserts a missing +`KAKEYA_REPO` yields a clear `RuntimeError` (no obscure ImportError, no core-dep creep). `3 passed`. --- diff --git a/docs/adr/0016-autonomous-agent-runtime.md b/docs/adr/0016-autonomous-agent-runtime.md index 2b7c9bd13..0f6bdd86c 100644 --- a/docs/adr/0016-autonomous-agent-runtime.md +++ b/docs/adr/0016-autonomous-agent-runtime.md @@ -24,9 +24,21 @@ runtime**. New module `services/agent_runtime/` exposed as `AGENT_RUNTIME_CMD` (`python -m services.agent_runtime.run --prompt … --out …`): -- **`llm.py`** — dependency-free (urllib) cloud-LLM client: `AGENT_LLM = anthropic|openai|kakeya|stub` - (auto-detected from `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`KAKEYA_ENDPOINT`). `complete_json` parses - + retries. Creative reasoning runs here; the host agent is no longer required at runtime. +- **`llm.py`** — LLM client: `AGENT_LLM = anthropic|openai|kakeya|kakeya_grpc|stub` (auto-detected + from `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`KAKEYA_GRPC_ADDRESS`/`KAKEYA_ENDPOINT`). `complete_json` + parses + retries. Creative reasoning runs here; the host agent is no longer required at runtime. + - The cloud + `kakeya` (HTTP shim) providers are **urllib-only** (no heavy deps). + - **`kakeya_grpc`** (the head's live path) talks **directly** to the user-run Kakeya engine over + its native gRPC `RuntimeService` (token-id level — the real stable surface; the HTTP shim is + deprecated/single-session). It is an **external dependency contract**: OpenMontage does **not** + vendor the Kakeya SDK/proto — point `KAKEYA_REPO` at a checkout (must expose a top-level `kakeya` + package + `sdks/python`) and the `kakeya`/`transformers` imports happen **lazily** only when this + provider is selected, so grpc/transformers never become core deps (ADR 0001 D5/D7). Env: + `KAKEYA_GRPC_ADDRESS` (e.g. `127.0.0.1:51051`), `KAKEYA_TOKENIZER_ID` (HF/MLX tokenizer dir; + falls back to `KAKEYA_VERIFIER_ID`). Flow: tokenizer `apply_chat_template(enable_thinking=False)` + → `Client.create_session(eos_token_ids=…)` → `append` → `generate(max_tokens)` → `decode`. + Host deps (head `~/.venv-distwan`): `grpcio`, `transformers`, `mlx`/`mlx-lm`. Missing + `KAKEYA_REPO` → clear actionable `RuntimeError`. - **`run.py`** — minimal unattended orchestrator that reuses the real machinery: - LLM produces **brief → script → scene_plan** (each schema-validated via `schemas.artifacts` and checkpointed via `lib/checkpoint`, exactly like the governed pipeline; director-skill text @@ -76,10 +88,10 @@ idea/script/scene_plan/assets/edit. The test asserts each scene received a **dir - Deploy: set `AGENT_RUNTIME_CMD=" -m services.agent_runtime.run"` + an LLM endpoint on the head; live-test `mode=agent` via `agent.kakeya.ai`. **LLM endpoint smoke test:** run `python -m services.agent_runtime.run --check-llm` first — one tiny round-trip prints - provider/model + latency and exits non-zero on failure, so the reasoner is verified before a full - (slow) pipeline run. With the head's Kakeya/Gemma runtime, point `AGENT_LLM=kakeya` + - `KAKEYA_ENDPOINT` at the OpenAI-compatible HTTP shim (the stable text surface; the gRPC - `RuntimeService` is token-id level). + provider/model + latency and exits 0/1/2 (ok/error/no-endpoint), so the reasoner is verified + before a full (slow) pipeline run. The head's live config uses **`AGENT_LLM=kakeya_grpc`** against + the Gemma 26B gRPC runtime (`KAKEYA_GRPC_ADDRESS=127.0.0.1:51051`, `KAKEYA_REPO`, `KAKEYA_TOKENIZER_ID`) + — `--check-llm` prints `OK`, confirming end-to-end reachability. - Configure premium video provider for production-grade clips (the quality answer from the draft-vs-premium discussion). - Grow toward the governed pipeline (proposal/approval/reviewer, more director skills) and more diff --git a/services/agent_runtime/llm.py b/services/agent_runtime/llm.py index cc86642bf..934c7f264 100644 --- a/services/agent_runtime/llm.py +++ b/services/agent_runtime/llm.py @@ -4,12 +4,18 @@ UNATTENDED behind the gateway's mode=agent, we need our own cloud-LLM reasoner. This is a thin, dependency-free client (urllib only) over the providers' HTTP APIs, selected by env: - AGENT_LLM = anthropic | openai | kakeya | stub (auto-detected from keys if unset) - ANTHROPIC_API_KEY / OPENAI_API_KEY / KAKEYA_ENDPOINT + AGENT_LLM = anthropic | openai | kakeya | kakeya_grpc | stub (auto-detected from keys if unset) + ANTHROPIC_API_KEY / OPENAI_API_KEY / KAKEYA_ENDPOINT / KAKEYA_GRPC_ADDRESS AGENT_LLM_MODEL (optional override) Creative reasoning (brief/script/scene_plan/edit) runs here; mechanical text can still use the in-repo kakeya_llm tool. `complete_json` returns a parsed dict, retrying once on bad JSON. + +The `kakeya_grpc` provider talks DIRECTLY to a user-run Kakeya inference server over its native +gRPC RuntimeService (token-id level — the real stable surface; the HTTP shim is deprecated). It is +an EXTERNAL dependency contract: OpenMontage does NOT vendor the Kakeya SDK/proto or add +grpc/transformers as core deps — point `KAKEYA_REPO` at a checkout of the Kakeya engine and those +imports happen lazily, only when this provider is selected. Other providers stay urllib-only. """ from __future__ import annotations @@ -24,6 +30,7 @@ "anthropic": "claude-3-5-sonnet-latest", "openai": "gpt-4o-mini", "kakeya": "kakeya-local", + "kakeya_grpc": "kakeya-local", } @@ -39,6 +46,8 @@ def __init__(self, provider: Optional[str] = None, model: Optional[str] = None, self.provider = "anthropic" elif os.environ.get("OPENAI_API_KEY"): self.provider = "openai" + elif os.environ.get("KAKEYA_GRPC_ADDRESS"): + self.provider = "kakeya_grpc" elif os.environ.get("KAKEYA_ENDPOINT"): self.provider = "kakeya" else: @@ -57,6 +66,8 @@ def complete(self, system: str, user: str, max_tokens: int = 3000) -> str: return self._openai(system, user, max_tokens) if self.provider == "kakeya": return self._openai(system, user, max_tokens, kakeya=True) + if self.provider == "kakeya_grpc": + return self._kakeya_grpc(system, user, max_tokens) raise RuntimeError(f"unknown AGENT_LLM provider: {self.provider}") def _post(self, url: str, headers: dict, body: dict, timeout: int = 180) -> dict: @@ -87,6 +98,50 @@ def _openai(self, system: str, user: str, max_tokens: int, kakeya: bool = False) {"role": "user", "content": user}]}) return out["choices"][0]["message"]["content"] + def _kakeya_grpc(self, system: str, user: str, max_tokens: int) -> str: + """Direct token-id-level generation over the Kakeya gRPC RuntimeService. + + External dependency contract: the Kakeya SDK/proto are NOT vendored — set KAKEYA_REPO to a + checkout of the engine (it must contain a top-level `kakeya` package and `sdks/python`). + Needs `grpcio` + `transformers` (and the tokenizer at KAKEYA_TOKENIZER_ID) on the host; + these are imported lazily so they never become core OpenMontage deps.""" + import sys + from pathlib import Path + + repo = os.environ.get("KAKEYA_REPO") + if not repo: + raise RuntimeError( + "kakeya_grpc requires KAKEYA_REPO (path to a Kakeya inference-engine checkout) so " + "its gRPC SDK can be imported. Also set KAKEYA_GRPC_ADDRESS (e.g. 127.0.0.1:51051) " + "and KAKEYA_TOKENIZER_ID (HF/MLX tokenizer dir).") + kakeya_root = Path(repo).expanduser() + for p in (str(kakeya_root), str(kakeya_root / "sdks" / "python")): + if p not in sys.path: + sys.path.insert(0, p) + from kakeya import Client # type: ignore # noqa: E402 + from transformers import AutoTokenizer # noqa: E402 + + address = os.environ.get("KAKEYA_GRPC_ADDRESS", "127.0.0.1:51051") + tokenizer_id = os.environ.get( + "KAKEYA_TOKENIZER_ID", os.environ.get("KAKEYA_VERIFIER_ID", self.model)) + tokenizer = AutoTokenizer.from_pretrained(tokenizer_id) + eos = tokenizer.eos_token_id + eos_ids = [int(eos)] if eos is not None else [] + token_ids = tokenizer.apply_chat_template( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + add_generation_prompt=True, tokenize=True, return_dict=False, enable_thinking=False) + with Client(address) as client: + session = client.create_session(eos_token_ids=eos_ids, client_label="openmontage-agent") + try: + session.append(token_ids) + generated = list(session.generate(max_tokens=max_tokens)) + finally: + try: + session.close() + except Exception: # noqa: BLE001 + pass + return tokenizer.decode(generated, skip_special_tokens=True) + # --- JSON helper -------------------------------------------------------- @staticmethod def _extract_json(text: str) -> dict: diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py index 4fd2a0c01..f460cc360 100644 --- a/services/agent_runtime/run.py +++ b/services/agent_runtime/run.py @@ -377,8 +377,8 @@ def check_llm() -> int: llm = LLM() log(f"LLM provider={llm.provider} model={llm.model or '(default)'}") if llm.provider == "stub": - log("LLM check: provider=stub — no real endpoint configured " - "(set AGENT_LLM + KAKEYA_ENDPOINT/ANTHROPIC_API_KEY/OPENAI_API_KEY).") + log("LLM check: provider=stub — no real endpoint configured (set AGENT_LLM + one of " + "KAKEYA_GRPC_ADDRESS / KAKEYA_ENDPOINT / ANTHROPIC_API_KEY / OPENAI_API_KEY).") return 2 t0 = time.time() try: diff --git a/tests/tools/test_agent_runtime.py b/tests/tools/test_agent_runtime.py index f44ff019a..868a86d3c 100644 --- a/tests/tools/test_agent_runtime.py +++ b/tests/tools/test_agent_runtime.py @@ -86,3 +86,15 @@ def test_llm_json_extraction(): from services.agent_runtime.llm import LLM llm = LLM(stub=lambda s, u: 'prefix ```json\n{"a": 1, "b": {"c": 2}}\n``` suffix') assert llm.complete_json("sys", "user") == {"a": 1, "b": {"c": 2}} + + +def test_kakeya_grpc_requires_repo(monkeypatch): + """kakeya_grpc is an external-dependency contract: without KAKEYA_REPO it must fail with a + clear, actionable error (not an obscure ImportError) and never become a core dep.""" + from services.agent_runtime.llm import LLM + monkeypatch.delenv("KAKEYA_REPO", raising=False) + monkeypatch.setenv("KAKEYA_GRPC_ADDRESS", "127.0.0.1:51051") + llm = LLM(provider="kakeya_grpc") + assert llm.provider == "kakeya_grpc" + with pytest.raises(RuntimeError, match="KAKEYA_REPO"): + llm.complete("sys", "user") From 4656fa3bdc49ea937a5869260d3c950bdbd169d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 24 Jun 2026 03:17:55 +0000 Subject: [PATCH 08/28] agent runtime: add --plan-only (validate Gemma + director without GPU/gateway/key) The public gateway enforces X-API-Key which the owner cannot retrieve, blocking mode=agent smoke tests. The runtime is a plain module; --plan-only runs LLM planning + prompt director, writes director_prompts.json, and exits before any render. Refactor run() to compute directed prompts up front. Offline plan-only test. Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 12 +++++- docs/adr/0016-autonomous-agent-runtime.md | 6 +++ services/agent_runtime/run.py | 44 ++++++++++++++------ tests/tools/test_agent_runtime.py | 18 ++++++++ 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index a9f247c62..cd6cc810a 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1207,7 +1207,17 @@ never enter OpenMontage's core deps (D5/D7). Flow: `apply_chat_template(enable_t `KAKEYA_GRPC_ADDRESS=127.0.0.1:51051`, `KAKEYA_TOKENIZER_ID` (→ `KAKEYA_VERIFIER_ID` fallback). Head deps in `~/.venv-distwan`: grpcio 1.81, transformers 5.12, mlx-lm 0.31. **Owner confirmed live:** `--check-llm` → `provider=kakeya_grpc model=kakeya-local` → `OK`. Offline guard test asserts a missing -`KAKEYA_REPO` yields a clear `RuntimeError` (no obscure ImportError, no core-dep creep). `3 passed`. +`KAKEYA_REPO` yields a clear `RuntimeError` (no obscure ImportError, no core-dep creep). + +**`--plan-only` (unblock testing without the gateway key).** The public gateway enforces +`X-API-Key` (`AGENT_GATEWAY_API_KEY`), which the owner couldn't retrieve — repeatedly blocking +`mode=agent` smoke tests via `agent.kakeya.ai`. Key insight: the agent runtime is a plain module; +the gateway is just a wrapper. Added `--plan-only` — runs LLM planning (brief→script→scene_plan) + +the prompt director, writes `director_prompts.json`, and **exits before any video render**. So the +owner validates Gemma + director on the head in minutes with **no GPU, no gateway, no key**: +`python -m services.agent_runtime.run --prompt "…" --plan-only`. Refactored `run()` to compute all +directed prompts up front (cleaner separation of director vs. generation). Offline test asserts +plan-only emits 2 directed prompts and renders no clips. `4 passed`. --- diff --git a/docs/adr/0016-autonomous-agent-runtime.md b/docs/adr/0016-autonomous-agent-runtime.md index 0f6bdd86c..32e2c042e 100644 --- a/docs/adr/0016-autonomous-agent-runtime.md +++ b/docs/adr/0016-autonomous-agent-runtime.md @@ -92,6 +92,12 @@ idea/script/scene_plan/assets/edit. The test asserts each scene received a **dir before a full (slow) pipeline run. The head's live config uses **`AGENT_LLM=kakeya_grpc`** against the Gemma 26B gRPC runtime (`KAKEYA_GRPC_ADDRESS=127.0.0.1:51051`, `KAKEYA_REPO`, `KAKEYA_TOKENIZER_ID`) — `--check-llm` prints `OK`, confirming end-to-end reachability. +- **`--plan-only`**: runs the LLM planning (brief→script→scene_plan) + the prompt director and writes + `director_prompts.json`, then exits **before any video render**. This is the fast, + **no-GPU / no-gateway / no-API-key** way to validate that the LLM + director actually work on the + head: `python -m services.agent_runtime.run --prompt "…" --plan-only`. The gateway's `X-API-Key` + (set as `AGENT_GATEWAY_API_KEY`) is only needed for the *public* path; the runtime itself is a plain + module and needs no key — run it directly to verify the feature. - Configure premium video provider for production-grade clips (the quality answer from the draft-vs-premium discussion). - Grow toward the governed pipeline (proposal/approval/reviewer, more director skills) and more diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py index f460cc360..43d3c2d4d 100644 --- a/services/agent_runtime/run.py +++ b/services/agent_runtime/run.py @@ -243,7 +243,8 @@ def plan_scenes(llm: LLM, script: dict) -> dict: # --------------------------------------------------------------------------- # # orchestration # --------------------------------------------------------------------------- # -def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None = None) -> str: +def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None = None, + plan_only: bool = False) -> str: from lib.checkpoint import write_checkpoint from schemas.artifacts import validate_artifact from tools.tool_registry import registry @@ -281,17 +282,32 @@ def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None # 4) assets: per-scene video (video_selector -> best provider) + narration; one music track sect_by_id = {s["id"]: s for s in script.get("sections", [])} + + # 3b) prompt director (LLM, no GPU): rewrite each scene into a rich native-T2V prompt up front, + # so the directed prompts can be inspected (and validated via --plan-only) before any rendering. vskill = _resolve_video_skill(registry) log(f"prompt-director: loaded provider guidance ({len(vskill)} chars)") - asset_list, cuts, director_prompts = [], [], [] + director_prompts = [] for i, sc in enumerate(scenes): - dur = max(2.0, float(sc.get("end_seconds", 0)) - float(sc.get("start_seconds", 0)) or 5.0) - vid = str(assets / f"scene_{i}.mp4") raw = sc.get("description", prompt) vprompt = direct_video_prompt(llm, raw, brief, vskill) director_prompts.append({"scene_id": sc.get("id", f"sc{i}"), "raw": raw, "prompt": vprompt}) log(f"scene {i}: directed prompt — {vprompt[:80]}") - vid = generate_clip(registry, vprompt, vid, dur, i) + dp_path = assets / "director_prompts.json" + try: + dp_path.write_text(json.dumps(director_prompts, indent=2)) + except OSError: + pass + if plan_only: + log(f"plan-only: planning + director done, skipping asset generation/compose -> {dp_path}") + return str(dp_path) + + # 4) assets: per-scene video (video_selector -> best provider) from the DIRECTED prompt + asset_list, cuts = [], [] + for i, sc in enumerate(scenes): + dur = max(2.0, float(sc.get("end_seconds", 0)) - float(sc.get("start_seconds", 0)) or 5.0) + vid = str(assets / f"scene_{i}.mp4") + vid = generate_clip(registry, director_prompts[i]["prompt"], vid, dur, i) asset_list.append({"id": f"v{i}", "type": "video", "path": vid, "source_tool": "video_selector", "scene_id": sc.get("id", f"sc{i}")}) cuts.append({"id": f"cut{i}", "source": vid, "in_seconds": 0.0, "out_seconds": dur, "speed": 1.0}) @@ -311,10 +327,6 @@ def run(prompt: str, out: str, llm: LLM | None = None, project_dir: Path | None "scene_id": scenes[0].get("id", "sc0") if scenes else "sc0"}) asset_manifest = {"version": "1.0", "assets": asset_list} validate_artifact("asset_manifest", asset_manifest); ck("assets", {"asset_manifest": asset_manifest}) - try: - (assets / "director_prompts.json").write_text(json.dumps(director_prompts, indent=2)) - except OSError: - pass # 5) edit_decisions (deterministic: clips in order, ffmpeg runtime) narrations = [a["path"] for a in asset_list if a["type"] == "narration"] @@ -398,13 +410,19 @@ def main(): ap.add_argument("--out", help="output mp4 path") ap.add_argument("--check-llm", action="store_true", help="only verify the LLM endpoint round-trips, then exit (no GPU/pipeline)") + ap.add_argument("--plan-only", action="store_true", + help="run LLM planning + prompt-director only (no video render); writes " + "director_prompts.json. Fast, no-GPU, no-gateway, no-key validation.") args = ap.parse_args() if args.check_llm: raise SystemExit(check_llm()) - if not args.prompt or not args.out: - ap.error("--prompt and --out are required (unless --check-llm)") - Path(args.out).parent.mkdir(parents=True, exist_ok=True) - run(args.prompt, args.out) + if not args.prompt: + ap.error("--prompt is required (unless --check-llm)") + if not args.plan_only and not args.out: + ap.error("--out is required (unless --check-llm or --plan-only)") + if args.out: + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + print(run(args.prompt, args.out or "", plan_only=args.plan_only)) if __name__ == "__main__": diff --git a/tests/tools/test_agent_runtime.py b/tests/tools/test_agent_runtime.py index 868a86d3c..9f6fa71e1 100644 --- a/tests/tools/test_agent_runtime.py +++ b/tests/tools/test_agent_runtime.py @@ -82,6 +82,24 @@ def test_agent_runtime_end_to_end(tmp_path, monkeypatch): assert entry["prompt"] != entry["raw"], entry +def test_agent_runtime_plan_only(tmp_path): + """--plan-only path: LLM planning + prompt-director, NO video render. Validates the directed + prompts (Gemma + director) can be produced/inspected without any GPU/gateway/key.""" + import importlib + import services.agent_runtime.run as run_mod + importlib.reload(run_mod) + from services.agent_runtime.llm import LLM + + res = run_mod.run("a red fox in a snowy forest", "", llm=LLM(stub=_stub), + project_dir=tmp_path / "proj", plan_only=True) + dp_path = Path(res) + assert dp_path.name == "director_prompts.json" and dp_path.exists() + dp = json.loads(dp_path.read_text()) + assert len(dp) == 2 and all(e["prompt"].startswith("directed cinematic shot, ") for e in dp) + # no clips/compose happened + assert not list((tmp_path / "proj" / "assets").glob("scene_*.mp4")) + + def test_llm_json_extraction(): from services.agent_runtime.llm import LLM llm = LLM(stub=lambda s, u: 'prefix ```json\n{"a": 1, "b": {"c": 2}}\n``` suffix') From a6f9d3bb1600445aee12b3c35d62df9f297920ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 24 Jun 2026 03:53:12 +0000 Subject: [PATCH 09/28] agent runtime: harden script stage against LLM schema drift Gemma's 4-bit script stage emitted enhancement_cues.type='visual' (out of the script schema enum), failing validation. Tighten plan_script prompt to the exact enum + minimal sections, and add defensive _sanitize_script() that drops unknown section keys and out-of-enum/malformed cues before validation. Folds owner's local plan_script patch into the repo. Offline test reproduces the drift. Live --plan-only on the head (real Gemma via kakeya_grpc) confirmed exit 0. Co-authored-by: FluffyAIcode --- docs/adr/0001-kakeya-integration-loop-log.md | 18 +++++++++- services/agent_runtime/run.py | 36 ++++++++++++++++++-- tests/tools/test_agent_runtime.py | 23 +++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/docs/adr/0001-kakeya-integration-loop-log.md b/docs/adr/0001-kakeya-integration-loop-log.md index cd6cc810a..479585034 100644 --- a/docs/adr/0001-kakeya-integration-loop-log.md +++ b/docs/adr/0001-kakeya-integration-loop-log.md @@ -1217,7 +1217,23 @@ the prompt director, writes `director_prompts.json`, and **exits before any vide owner validates Gemma + director on the head in minutes with **no GPU, no gateway, no key**: `python -m services.agent_runtime.run --prompt "…" --plan-only`. Refactored `run()` to compute all directed prompts up front (cleaner separation of director vs. generation). Offline test asserts -plan-only emits 2 directed prompts and renders no clips. `4 passed`. +plan-only emits 2 directed prompts and renders no clips. + +**LIVE validation on the head (real Gemma, no GPU/gateway/key).** Owner ran `--plan-only` against the +`kakeya_grpc` runtime → exit 0. Log: `provider=kakeya_grpc model=kakeya-local fake_assets=False` → +`planning brief…` (`brief: The Dawn Hunter`) → `planning script/scenes…` → `3 scenes` → +`prompt-director: loaded provider guidance (8000 chars)` → 3× `scene i: directed prompt — ` → `director_prompts.json`. **Gap B core loop (Gemma planning + Layer-3 prompt +director) is proven live.** + +**Schema-drift fix (`enhancement_cues.type`).** Gemma's 4-bit script stage emitted an out-of-enum +`enhancement_cues.type="visual"`, failing the strict `script.schema.json` (enum: overlay/broll/diagram/ +stat_card/code_snippet/animation; sections `additionalProperties:false`). Hardened `plan_script`: the +prompt now lists the exact enum + "keep sections minimal, no unknown fields", AND a defensive +`_sanitize_script()` strips unknown section keys and drops out-of-enum/malformed cues before +validation (prompt steering alone is fragile for a 4-bit model). Offline test reproduces the +`type="visual"` drift and asserts the sanitized script passes the schema. `5 passed`. (Folds the +owner's local plan_script patch into the repo so head==repo, no divergence.) --- diff --git a/services/agent_runtime/run.py b/services/agent_runtime/run.py index 43d3c2d4d..92dab13eb 100644 --- a/services/agent_runtime/run.py +++ b/services/agent_runtime/run.py @@ -222,13 +222,45 @@ def plan_brief(llm: LLM, prompt: str) -> dict: return llm.complete_json(sys_p, f"User brief: {prompt}") +# script.schema.json: sections are strict (additionalProperties:false) and enhancement_cues[].type +# is an enum. A 4-bit local model (Gemma) drifts — e.g. it emitted type="visual" — so we both steer +# the prompt AND defensively sanitize before validation so a stray field never fails the pipeline. +_SCRIPT_SECTION_KEYS = {"id", "label", "text", "start_seconds", "end_seconds", + "speaker_directions", "enhancement_cues", "pronunciation_guides", "source_ref"} +_CUE_TYPES = {"overlay", "broll", "diagram", "stat_card", "code_snippet", "animation"} + + +def _sanitize_script(script: dict) -> dict: + """Drop fields that would fail the strict script schema (LLM drift guard). + + Keeps only known section keys and filters enhancement_cues to the allowed `type` enum (dropping + cues that are malformed or out-of-enum, e.g. the observed type="visual").""" + for sec in script.get("sections", []): + if not isinstance(sec, dict): + continue + for k in [k for k in sec if k not in _SCRIPT_SECTION_KEYS]: + sec.pop(k, None) + cues = sec.get("enhancement_cues") + if isinstance(cues, list): + kept = [c for c in cues if isinstance(c, dict) and c.get("type") in _CUE_TYPES + and c.get("description")] + if kept: + sec["enhancement_cues"] = kept + else: + sec.pop("enhancement_cues", None) + return script + + def plan_script(llm: LLM, brief: dict) -> dict: dur = int(brief.get("target_duration_seconds", 20)) sys_p = ("You are OpenMontage's script director. Output a `script` JSON artifact. Required: " "version='1.0', title, total_duration_seconds (integer), sections (array). Each section: " - "id, text, start_seconds, end_seconds (contiguous, covering 0..total). Keep it concise. " + "id, text, start_seconds, end_seconds (contiguous, covering 0..total). Keep sections " + "MINIMAL (only those fields). The schema is STRICT: do not add unknown fields. Omit " + "`enhancement_cues` unless needed; if present, each cue.type MUST be exactly one of " + "[overlay, broll, diagram, stat_card, code_snippet, animation] (NOT 'visual'). " + _skill("pipelines/explainer/script-director")) - return llm.complete_json(sys_p, f"Brief:\n{json.dumps(brief)}\nTotal duration ~{dur}s.") + return _sanitize_script(llm.complete_json(sys_p, f"Brief:\n{json.dumps(brief)}\nTotal duration ~{dur}s.")) def plan_scenes(llm: LLM, script: dict) -> dict: diff --git a/tests/tools/test_agent_runtime.py b/tests/tools/test_agent_runtime.py index 9f6fa71e1..77a238229 100644 --- a/tests/tools/test_agent_runtime.py +++ b/tests/tools/test_agent_runtime.py @@ -106,6 +106,29 @@ def test_llm_json_extraction(): assert llm.complete_json("sys", "user") == {"a": 1, "b": {"c": 2}} +def test_sanitize_script_drops_invalid_cues(): + """Guard the observed Gemma drift: an out-of-enum enhancement_cues.type (e.g. 'visual') and + unknown section fields must be stripped so the strict script schema still validates.""" + import services.agent_runtime.run as run_mod + from schemas.artifacts import validate_artifact + script = { + "version": "1.0", "title": "T", "total_duration_seconds": 10, + "sections": [ + {"id": "s1", "text": "a", "start_seconds": 0, "end_seconds": 5, "bogus_field": 1, + "enhancement_cues": [{"type": "visual", "description": "bad"}, + {"type": "broll", "description": "ok"}]}, + {"id": "s2", "text": "b", "start_seconds": 5, "end_seconds": 10, + "enhancement_cues": [{"type": "nope", "description": "x"}]}, + ], + } + out = run_mod._sanitize_script(script) + s1, s2 = out["sections"] + assert "bogus_field" not in s1 + assert [c["type"] for c in s1["enhancement_cues"]] == ["broll"] # 'visual' dropped + assert "enhancement_cues" not in s2 # all-invalid -> removed + validate_artifact("script", out) # now passes the strict schema + + def test_kakeya_grpc_requires_repo(monkeypatch): """kakeya_grpc is an external-dependency contract: without KAKEYA_REPO it must fail with a clear, actionable error (not an obscure ImportError) and never become a core dep.""" From b991653d00f632c9fd958564c259a0bee9aa6eb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 24 Jun 2026 04:18:28 +0000 Subject: [PATCH 10/28] gateway UI: remember API key in browser (localStorage) The X-API-Key requirement repeatedly blocked the web UI because the key had to be re-typed each time. Prefill the key field from localStorage on load and persist it on submit, so it is entered once per browser. Co-authored-by: FluffyAIcode --- services/agent_gateway/server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/agent_gateway/server.py b/services/agent_gateway/server.py index 6745fa2ef..27bc08ed6 100644 --- a/services/agent_gateway/server.py +++ b/services/agent_gateway/server.py @@ -430,6 +430,8 @@ def index():