diff --git a/builder/builder/doctype/builder_variable/__init__.py b/builder/ai/agent/__init__.py similarity index 100% rename from builder/builder/doctype/builder_variable/__init__.py rename to builder/ai/agent/__init__.py diff --git a/builder/ai/agent/artifact.py b/builder/ai/agent/artifact.py new file mode 100644 index 000000000..0d7e97a3a --- /dev/null +++ b/builder/ai/agent/artifact.py @@ -0,0 +1,257 @@ +"""Artifact generators — produce a large streamed artifact for a tool. + +An *artifact tool* (one that sets `artifact=` on its `Tool`) delegates its +execution to a generator here. The generator runs on the user's selected *heavy* +model and streams the artifact to the client as plain content — reliable, unlike +tool-call argument streaming, which providers buffer (the canvas would stay +blank for the whole completion). After streaming, it returns the canonical +client op(s) for the loop to emit so the frontend applies the authoritative, +fully-parsed result. + +The agent calling the tool is the only trigger: when the fast conversational +model decides to build the page, it calls `generate_page(brief=…)` and the loop +hands off here. No DB status or out-of-band heuristic gates generation. +""" + +import base64 +import logging +import re + +import frappe + +from builder.ai import llm +from builder.ai.block_codec import BlockCodec +from builder.ai.models import ModelRegistry +from builder.ai.prompts import Prompts +from builder.ai.session import AISession + +logger = frappe.logger("builder.ai.agent.artifact") +logger.setLevel(logging.INFO) + +# Brief marker lines the design flow emits for images the generator should SEE +# (not just place): the user's reference and the chosen hero shot. +IMAGE_MARKER_RE = re.compile(r"(?:REFERENCE|HERO) IMAGE:\s*(\S+)", re.IGNORECASE) +MAX_ATTACHED_IMAGES = 2 +MAX_IMAGE_BYTES = 3 * 1024 * 1024 + + +def brief_image_parts(brief: str) -> list[dict]: + """Resolve the brief's image markers into message image parts. https URLs are + attached directly (the provider fetches them); /files/ paths are read from the + site and inlined as data URLs. The marker lines always stay in the brief text, + so the model still knows the exact URLs to place in blocks.""" + parts = [] + for url in IMAGE_MARKER_RE.findall(brief or ""): + if len(parts) >= MAX_ATTACHED_IMAGES: + break + if url.startswith("https://"): + parts.append({"type": "image_url", "image_url": {"url": url}}) + elif url.startswith("/files/"): + data_url = read_site_image(url) + if data_url: + parts.append({"type": "image_url", "image_url": {"url": data_url}}) + return parts + + +def read_site_image(file_url: str) -> str | None: + try: + name = frappe.db.get_value("File", {"file_url": file_url}, "name") + if not name: + return None + content = frappe.get_doc("File", name).get_content() + if isinstance(content, str): + content = content.encode() + if not content or len(content) > MAX_IMAGE_BYTES: + return None + ext = (file_url.rsplit(".", 1)[-1] or "png").lower() + mime = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png", "webp": "webp", "gif": "gif"}.get(ext) + if not mime: + return None + return f"data:image/{mime};base64,{base64.b64encode(content).decode()}" + except Exception as e: + logger.warning(f"read_site_image failed for {file_url}: {e}") + return None + + +def log_generation_quality(model: str, finish_reason: str | None, yaml_text: str) -> None: + """Make the generation path debuggable: log model, finish_reason, YAML size, parse + result, and top-level section count. A thin/broken page shows up here as a 'length' + finish, a parse error, or very few sections — distinguishing a weak model from a + pipeline bug.""" + chars = len(yaml_text) + sections = -1 # -1 = did not parse + try: + from builder.ai.page_writer import parse_generation_yaml, unwrap_root + + # The same salvaging parser persist_page uses — telemetry must agree with + # what actually lands on the page (a salvaged parse is not a failed one). + root = unwrap_root(parse_generation_yaml(yaml_text)) + if isinstance(root, dict): + sections = len(root.get("c") or []) + except Exception as e: + logger.warning("generate_page: YAML did not parse (model=%s): %s", model, e) + + level = logging.WARNING if (finish_reason == "length" or sections in (-1, 0, 1)) else logging.INFO + logger.log( + level, + "generate_page quality | model=%s finish_reason=%s yaml_chars=%d top_sections=%s", + model, + finish_reason, + chars, + sections, + ) + if sections in (-1, 0): + # A no-blocks generation costs a full retry — capture enough of the actual + # output to diagnose WHAT didn't parse (bad quoting, prose preamble, …). + logger.warning("generate_page unparsed head:\n%s", yaml_text[:800]) + logger.warning("generate_page unparsed tail:\n%s", yaml_text[-400:]) + + +def stream_buffer_key(page_id: str) -> str: + return f"builder_ai_page_stream:{page_id}" + + +def save_stream_buffer(ctx, yaml_content: str) -> None: + """Keep the in-flight generation stream in Redis so an editor that loads (or + refreshes) mid-build can replay the preview instead of showing a stale draft.""" + frappe.cache().set_value( + stream_buffer_key(ctx.page_id), + frappe.as_json( + {"yaml": yaml_content, "session_id": ctx.session_id, "origin_page": ctx.canvas_page_id} + ), + expires_in_sec=600, + ) + + +def clear_stream_buffer(page_id: str | None) -> None: + if page_id: + frappe.cache().delete_value(stream_buffer_key(page_id)) + + +def generate_page_yaml(ctx, args: dict) -> list[dict]: + """Stream a complete page of YAML on the heavy model, persist it to the page + (the server is authoritative), and return a `generate_page` client op carrying + the expanded block tree — the canvas applies that, so both sides share block ids. + + `ctx` is the AgentRunner. `args["brief"]` is the concise spec the + conversational model assembled from the approved plan / conversation. + Streams `kind="page_yaml"` chunks to the canvas as the model writes them + (live preview only — the returned op is the final word). + Returns [] if the model produced nothing usable. + """ + brief = (args.get("brief") or "").strip() + + messages: list[dict] = [ + # 1h TTL: the generation system prompt is identical across every build on + # the site, so it stays a cache read from one page build to the next. + { + "role": "system", + "content": Prompts.GENERATION_YAML, + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + }, + ] + # Prior conversation (incl. the approved plan) as proper role-tagged turns. + messages.extend(AISession.build_context_messages_from_id(ctx.session_id)) + # The approved wireframes (plan strip + chosen layout sketch) as plain SVG text. + # Cards replay as text where svg degrades to '[sketch]', so without this the + # generator never sees the composition the user actually approved. + if svgs := AISession.collect_design_svgs(ctx.session_id): + messages.append( + { + "role": "user", + "content": "Approved wireframes (abstract layout sketches — match this composition and " + "rhythm, not the literal shapes):\n" + "\n".join(svgs), + } + ) + if brief: + build_text = f"Build this page now:\n{brief}" + # Vision models get the reference/hero images themselves, not just their + # URLs: the user's attached image for THIS turn, plus the brief's marker + # images. Text-only models keep working from the marker URLs. + image_parts: list[dict] = [] + if ModelRegistry.supports_vision(ctx.model): + if ctx.image_url: + image_parts.append({"type": "image_url", "image_url": {"url": ctx.image_url}}) + image_parts.extend(brief_image_parts(brief)) + if image_parts: + messages.append({"role": "user", "content": [{"type": "text", "text": build_text}, *image_parts]}) + else: + messages.append({"role": "user", "content": build_text}) + + ctx.emit("progress", message="Building the page…") + + yaml_content = "" + finish_reason = None + buffered_at = 0 + stream = llm.complete( + ctx.model, # heavy model — generation quality + messages, + llm.TASK_PARAMS["complex"], + stream=True, + api_key=ctx.api_key, + ) + try: + for chunk in stream: + if ctx.is_cancelled(): + try: + stream.close() + except Exception: + pass + from builder.ai.agent.loop import CancelledError + + raise CancelledError + ctx.record_usage(chunk, model=ctx.model) # generation streams on the heavy model + if not chunk.choices: + continue + if fr := chunk.choices[0].finish_reason: + finish_reason = fr + delta = chunk.choices[0].delta.content + if delta: + # offset = position of this chunk in the full stream, so a client that + # replayed the buffer (mid-build refresh) can drop duplicates / detect gaps. + offset = len(yaml_content) + yaml_content += delta + # Every event names its TARGET page (ctx.page_id): the canvas renders the + # stream only when it is showing that page — an off-canvas build must + # never paint (and get autosaved) over a page it wasn't meant for. + if not ctx.headless: + ctx.emit("stream", chunk=delta, kind="page_yaml", offset=offset) + if ctx.headless or ctx.page_id != ctx.canvas_page_id: + # An editor open on the target page is the live viewport ("watch it + # build") — headless chats have no canvas, editor chats may have + # focused a different page than the one on their canvas. + ctx.emit_page("stream", chunk=delta, kind="page_yaml", offset=offset) + if len(yaml_content) - buffered_at >= 512: + buffered_at = len(yaml_content) + save_stream_buffer(ctx, yaml_content) + finally: + # The buffer only serves mid-build refreshes; once this function returns the + # draft is persisted (or the turn failed/cancelled) and the DB is the truth. + clear_stream_buffer(ctx.page_id) + + yaml_text = BlockCodec.strip_fences(yaml_content) + # Generation was a blind spot — log enough to explain a thin/broken/truncated page: + # the model, finish_reason (="length" → ran out of tokens mid-page), the YAML size, + # whether it parses, and how many top-level sections (root.c) it actually produced. + log_generation_quality(ctx.model, finish_reason, yaml_text) + if not yaml_text or not ctx.page_id: + logger.warning("generate_page_yaml: nothing to persist (model=%s, page=%s)", ctx.model, ctx.page_id) + return [] + + from builder.ai import page_writer + + root, data_script = page_writer.persist_page(ctx.page_id, yaml_text) + if root is None: + # A discarded generation is paid-for work — keep the raw text for diagnosis + # (the parse error itself is logged by parse_generation_yaml with line info). + dump = frappe.get_site_path("private", "files", f"ai-failed-generation-{ctx.page_id}.yaml") + try: + with open(dump, "w") as f: + f.write(yaml_text) + except OSError: + dump = "" + logger.warning( + "generate_page_yaml: YAML produced no blocks (model=%s), raw saved to %s", ctx.model, dump + ) + return [] + return [{"tool_name": "generate_page", "args": {"blocks": [root], "data_script": data_script}}] diff --git a/builder/ai/agent/loop.py b/builder/ai/agent/loop.py new file mode 100644 index 000000000..7e98e359a --- /dev/null +++ b/builder/ai/agent/loop.py @@ -0,0 +1,1682 @@ +"""The single agentic loop for Builder AI. + +`AgentRunner` holds the per-request state, builds the message list, and drives +one tool-calling loop until the model stops requesting tools. Tool *behaviour* +lives in the registry; this file only orchestrates. + +The server is authoritative for every turn: the page is loaded from the DB into +a mutating `WorkingTree`, ops are applied there first and persisted after each +round, and the accepted ops are mirrored to the editor canvas (which is a live +VIEW, not a second source of truth). Ops the tree rejects are never emitted, so +canvas and server can't diverge. + +Realtime event contract (consumed by the frontend). Every event name is +suffixed with the CHANNEL — the page id for the in-editor chat, or the session +id when page-less (dashboard chat + sub-agents), e.g. `ai_chat_stream_`: + + ai_chat_progress {message} + ai_chat_stream {chunk, kind?} kind="page_yaml" → live canvas preview; + absent/"summary" → append to chat text + ai_chat_tool_batch {operations: [{tool_name, args}]} + (generate_page args carry the expanded {blocks, data_script}; + add_block args carry block_json — the canvas applies those + verbatim so both sides share block ids) + ai_chat_tool_activity {id, tool, summary, status: "running"|"done", image_url?} + (same id emitted twice — running then done; upsert by id) + ai_chat_clarify {question, ui: [element]} generic card the agent + composed (present_ui); renderer skips unknown element + kinds. Confirm-gated actions add {pending_action}. + ai_chat_complete {message} + ai_chat_error {message} + +All events also carry {page_id}. Clarify messages are persisted+committed +before the event fires, so a session reload on receipt is race-free. +""" + +import json +import logging +import re +import time + +import frappe + +from builder.ai import llm, locks +from builder.ai.agent.registry import ToolRegistry, build_default_registry +from builder.ai.agent.tree import WorkingTree +from builder.ai.block_codec import BlockCodec +from builder.ai.models import ModelRegistry +from builder.ai.prompts import Prompts +from builder.ai.session import AISession +from builder.ai.snapshots import capture_page_state, save_revert_snapshot +from builder.utils import to_compact_yaml + +logger = frappe.logger("builder.ai.agent.loop") +logger.setLevel(logging.INFO) + +# One turn may span several rounds: server-tool reads, plus a model that applies a +# page-wide change in batches across rounds. High enough to finish a big multi-block +# fix (a "fix everything" can drip a few edits per round on weaker models), bounded so +# a runaway loop can't spin. When the cap IS hit the turn ends with a "continue" hint. +MAX_ROUNDS = 40 +EVENT_PREFIX = "ai_chat" + +# A streaming round is retried on transient failure (litellm can't fall back mid-stream). +# Backoff is STREAM_BACKOFF_BASE * 2**attempt → ~1s, 2s before the final give-up. +STREAM_MAX_ATTEMPTS = 3 +STREAM_BACKOFF_BASE = 1.0 + +# Tools whose changes a pre-turn snapshot can revert. The snapshot captures blocks + +# page data + client scripts, so block edits AND script create/edit are all undone by +# one "Revert" — no separate "undo script" action. A turn touching none of these (clarify, +# plan, no-op) creates no snapshot and gets no Revert button. +SNAPSHOT_TOOLS = frozenset( + { + "add_block", + "update_block", + "update_blocks", + "remove_block", + "move_block", + "set_page_blocks", + "generate_page", + "set_page_script", + "update_script", + } +) + +# Script tools ALWAYS apply through their server handlers, editor sessions included. +# Applying them in the browser (frappe.client.insert from toolDispatch) lost scripts +# silently — two parallel attaches in one round raced and .catch(() => null) ate the +# failure; a page then published with its reveal CSS but not the JS that fires it. +# The server apply is atomic and verified; the canvas just mirrors the result. +SCRIPT_TWIN_TOOLS = frozenset({"set_page_script", "update_script"}) + + +class CancelledError(Exception): + """Raised inside the stream loops when the user cancels the turn.""" + + +def looks_like_page_yaml(text: str) -> bool: + """Heuristic: did the model emit page YAML as plain content?""" + if not text: + return False + stripped = re.sub(r"^```(?:yaml)?\s*", "", text.strip()) + return stripped.startswith(("el:", "- el:", "id: root", "- id:")) + + +# First-person / sentence-initial past-tense claims that the page was changed. The +# no-op-claim guard uses this: if the model says it did the work but called no tool, +# nothing was applied — a hallucinated success (weaker models narrate the action +# instead of doing it). Anchored to "I added…" / "Added a…" shapes so a truthful +# answer ABOUT past work ("your page was created last week") doesn't trip it. +ACTION_VERBS = ( + "added|created|updated|changed|removed|deleted|applied|attached|inserted|" + "replaced|moved|translated|restyled|recolou?red|rebuilt|built|wired|enabled|" + "adjusted|swapped|renamed|resized|reordered|set up" +) +ACTION_CLAIM_RE = re.compile( + rf"\b(?:I|I've|I have|we|we've)(?:\s+\w+){{0,2}}\s+(?:{ACTION_VERBS})\b" + rf"|^\s*(?:{ACTION_VERBS})\b", + re.IGNORECASE | re.MULTILINE, +) + +NOOP_CORRECTION = ( + "You wrote a summary describing changes, but you called no tools — so NOTHING was " + "applied to the page. If the request needs a change, call the appropriate tool(s) now " + "(update_block/add_block for targeted edits, run_python for bulk mutations, " + "set_page_script, generate_page, …) and actually do the work. If no change is genuinely " + "needed, or you were only answering a question, reply plainly and do NOT claim you " + "changed anything." +) + + +def claims_unbacked_action(summary_text: str) -> bool: + """True if the summary reads like a completed edit ('Added a confetti burst…').""" + return bool(summary_text) and bool(ACTION_CLAIM_RE.search(summary_text)) + + +# Persisted present_ui cards replay to the model as plain text ("[buttons: …]"), +# and a model can MIMIC that format — writing a card as chat text instead of +# calling present_ui. Text renders no controls, so the user is stuck. +# Any card-atom name in bracket notation is mimicry, never natural prose — cover the +# replay vocabulary AND the schema vocabulary (a model can leak either: Kimi wrote +# "[actions: Continue]", blending the schema's kind name into the replay format). +CARD_TEXT_RE = re.compile( + r"\[\s*(?:input|choices|buttons|upload|swatches|sketch|actions|color_input)\s*:" + r"|\[\s*colou?r picker\b", + re.IGNORECASE, +) + +CARD_CORRECTION = ( + "Your last message wrote an interactive card as plain TEXT (markup like [input: …] " + "or a raw JSON blob). Text renders NO controls — the user cannot answer it. " + "Ask again properly: ONE present_ui call composing the same fields from its ui atoms " + "(input/choices/upload/actions), following the tool's exact schema. Do not repeat " + "the markup or JSON in your text." +) + + +def looks_like_card_text(summary_text: str) -> bool: + return bool(summary_text) and bool(CARD_TEXT_RE.search(summary_text)) + + +# The subtler mimic: a question with enumerated options written as clean prose +# ("Choose a typography pairing: • Syne + Albert Sans — … • …"). No card markup, +# so CARD_TEXT_RE misses it — but the user gets a dead list instead of tappable +# chips, and one such message in the history teaches the model to answer every +# later question the same way (the design flow degrades permanently). +BULLET_LINE_RE = re.compile(r"(?m)^\s*(?:[-*•]|\d+[.)])\s+\S") +ASKS_CHOICE_RE = re.compile( + r"(?mi)^\s*(?:choose|pick|select|which|what(?:'s| is)? your|would you (?:like|prefer)|let me know which|" + r"here are|let's (?:explore|look at)|consider (?:these|the following)|a few (?:more )?options)\b" +) +# Option-DECORATION markers (fonts/palette/layout/image) are the exact format +# option_text() replays a card in — a model writing "[fonts: Fraunces + Albert Sans]" +# in a bullet is mimicking a past card as prose, whatever the lead-in reads like. +# Control-atom markers (actions/buttons/input/…) count the same way: a bulleted +# question that leaks ANY card notation is a card written as text. +OPTION_MARKER_RE = re.compile( + r"\[\s*(?:fonts?|palette|layout|image|actions|buttons|input|choices|upload|swatches)\s*:", + re.IGNORECASE, +) + +BUILD_INCOMPLETE_CORRECTION = ( + "You set up the design system (tokens, scripts, page settings) but NEVER built the page — " + "it is still EMPTY. Call generate_page NOW with a full brief: the chosen layout SYSTEM and " + "its signature move, the font pairing, the palette and spacing as the exact var(--) token " + "handles you just minted, every section with real copy, and the class hooks your scripts " + "target. The turn is NOT done until the page has content." +) + +OPTIONS_AS_TEXT_CORRECTION = ( + "You ended your turn by asking a question with a LIST OF OPTIONS as plain text — text " + "renders no controls, so the user has nothing to tap. Ask it again as ONE present_ui " + "call: a single short lead-in `text` atom, then a `choices` group with one option per " + "item (label + short description; include `font` for font pairings and `svg`+`colors` " + "for layout directions so previews render). A single-question card needs no extra " + "button. Do NOT repeat the options in your message text." +) + + +def asks_options_as_text(summary_text: str) -> bool: + """True when a no-tool round poses a multi-option question as prose (2+ bullets + plus either a question, a presenting lead-in, or leaked card-option markers).""" + text = (summary_text or "").strip() + if not text or len(BULLET_LINE_RE.findall(text)) < 2: + return False + return "?" in text or bool(ASKS_CHOICE_RE.search(text)) or bool(OPTION_MARKER_RE.search(text)) + + +# Weaker models sometimes emit a pseudo tool call as plain TEXT instead of calling +# the tool ("calc:default_api:write_page_data_script{…}", "```tool_code…"). That +# must never reach the chat as the turn's summary. Conservative signals only — +# `default_api` is Gemini's function namespace, never natural prose. +TOOL_SYNTAX_RE = re.compile(r"\bdefault_api\b| bool: + return bool(text) and bool(TOOL_SYNTAX_RE.search(text)) + + +# Weaker models sometimes emit their tool call as a plain-text JSON blob instead +# of a native tool call — without salvage the raw JSON lands in the chat as the +# turn's summary. Two salvageable shapes (an optional prose prefix is tolerated): +# a wrapped call ({"type": "present_ui", "args": {…}}) naming a REGISTERED tool, +# and bare present_ui args ({"text": …, "ui": […]} — the exact schema, nothing +# looser). Card-ish JSON that matches neither (hallucinated schemas) gets the +# corrective round instead — see looks_like_json_card. +TEXT_TOOL_NAME_KEYS = ("type", "name", "tool", "tool_name") +TEXT_TOOL_ARG_KEYS = ("args", "arguments", "parameters", "input") +UI_CARD_KEYS = frozenset({"ui", "choices", "options", "buttons", "inputs", "swatches", "upload"}) + + +def split_trailing_json(text: str) -> tuple[str, str | None]: + """Split "prose… {json}" into (prose, blob). The blob must run to the end of + the message; code fences are tolerated. blob is None when there isn't one.""" + text = (text or "").strip() + if text.startswith("```"): + text = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", text).strip() + start = text.find("{") + if start == -1 or not text.endswith("}"): + return text, None + return text[:start].strip(), text[start:] + + +def parse_text_tool_call(text: str, known_tools: list[str]) -> tuple[str, dict, str] | None: + """Salvage a tool call the model wrote as text. Returns (tool_name, args, + prose_prefix) or None when nothing safely matches.""" + prose, blob = split_trailing_json(text) + if not blob: + return None + parsed, _ = llm.loads_tolerant(blob) + if not isinstance(parsed, dict): + return None + name = next((parsed[k] for k in TEXT_TOOL_NAME_KEYS if isinstance(parsed.get(k), str)), None) + if name in known_tools: + args = next((parsed[k] for k in TEXT_TOOL_ARG_KEYS if isinstance(parsed.get(k), dict)), {}) + return name, args, prose + if ( + "present_ui" in known_tools + and isinstance(parsed.get("text"), str) + and isinstance(parsed.get("ui"), list) + ): + return "present_ui", parsed, prose + return None + + +def looks_like_json_card(text: str) -> bool: + """A JSON blob that TRIES to be an interactive card but matches no salvageable + shape (hallucinated schema, e.g. {"text": …, "choices": {…}, "actions": {…}}). + Mapping arbitrary invented schemas is a losing game — send the model a + corrective round instead.""" + _, blob = split_trailing_json(text) + if not blob: + return False + parsed, _ = llm.loads_tolerant(blob) + return ( + isinstance(parsed, dict) + and isinstance(parsed.get("text"), str) + and bool(UI_CARD_KEYS & parsed.keys()) + ) + + +# Above this many chars of compact-YAML page structure, switch the page context +# from the full tree to a compact outline (read_block pulls detail on demand). +# Tuned so a typical multi-section page still ships in full; only big pages skeletonise. +FULL_CONTEXT_LIMIT = 9000 + +# Tools that already surface as their own card in the chat (clarify question, plan, +# task group) — no activity line for them. +ACTIVITY_SILENT = frozenset({"present_ui", "spawn_parallel_agents"}) + +# Server tools that only READ. Everything else that runs server-side (settings, theme, +# data scripts, page creation, generation…) mutates real state — the no-op-claim guards +# must count that as backing for an action claim, or a purely-server-tool turn (the +# dashboard's normal mode) gets its truthful summary replaced with "I didn't apply it". +READ_ONLY_SERVER_TOOLS = frozenset( + { + "read_page", + "open_page", + "query_blocks", + "read_block", + "get_document", + "query_records", + "list_doctypes", + "get_doctype_schema", + "get_page_scripts", + "preview_page", + } +) + +# --- prompt-cache breakpoints (Claude via OpenRouter; stripped elsewhere) ------ +# Ported from the agent-v2 rewrite, where this scheme measured ~90% cache reads +# on real multi-round builds (~80% input-cost cut). The system breakpoint holds +# the prompt + tools; user turns are minutes apart, so the default 5-minute TTL +# would expire between turns — 1h costs 2x to write but breaks even by the third +# turn of a session. +SYSTEM_CACHE_CONTROL = {"type": "ephemeral", "ttl": "1h"} +TURN_CACHE_CONTROL = {"type": "ephemeral"} +# Anthropic allows at most 4 cache_control markers per request. +MAX_CACHE_MARKERS = 4 +# Anthropic matches an existing cache entry only within ~20 content blocks +# behind a marker; long turns get a mid-turn anchor every this many messages so +# consecutive rounds always land inside the lookback window. +MID_TURN_MARKER_EVERY = 15 + + +def render_page_context(root: dict | None, selected_block_ids: tuple | list = ()) -> str: + """Render a page's block tree as model-readable context: the full compact YAML + for a normal page, or an outline (+ full detail for selected blocks) past + FULL_CONTEXT_LIMIT. Shared by the turn's page-context message and the page + tools (open_page / create_page / read_page).""" + if root is None: + return "" + full = to_compact_yaml(BlockCodec.compress(root, depth=0, task_tier="complex")) + # Small pages: ship the full structure — cheapest path is no extra read_block + # round-trips, and the model can match existing styles directly. Big pages: ship + # a compact outline instead (styles/attrs omitted) and let the model pull detail + # on demand with read_block. The threshold is on the full serialisation length, + # which tracks token cost closely. + if len(full) <= FULL_CONTEXT_LIMIT: + return f"Current page structure (YAML — pass a block's 'ref' value as block_id to edit it):\n{full}" + return render_skeleton_context(root, selected_block_ids) + + +def render_skeleton_context(root: dict, selected_block_ids: tuple | list = ()) -> str: + """Outline + full detail for any blocks the user has selected (so the common + targeted-edit case needs no read_block round-trip).""" + from builder.ai.agent.selectors import find_block, render_skeleton + + outline = render_skeleton(root) + parts = [ + "This page is large, so you're given a compact OUTLINE (one line per block: " + "indentation = nesting, then ref, element, optional name, and a short text " + "preview). Styles and attributes are omitted. Pass a block's ref as block_id to " + "edit it. To see a block's full styles/attributes/text before editing, call " + "read_block(ref); to act on many blocks at once, call query_blocks then " + "update_blocks, or run_python (refs are blockIds in the `page` dict it sees).", + outline, + ] + for ref in selected_block_ids: + block = find_block(root, ref) + if block is None: + continue + detail = to_compact_yaml(BlockCodec.compress(block, depth=0, task_tier="complex")) + parts.append(f"Full detail for selected block {ref}:\n{detail}") + return "\n\n".join(parts) + + +def activity_summary(tool_name: str, args: dict, tree=None) -> str: + """A short human line for the chat's live activity feed ("Read page: Home").""" + args = args or {} + + def page_title(page_id: str | None) -> str: + return (page_id and frappe.db.get_value("Builder Page", page_id, "page_title")) or page_id or "…" + + def block_label(ref: str | None) -> str: + block = tree.resolve(ref) if (tree and ref) else None + if block: + return block.get("blockName") or f"<{block.get('element') or 'div'}>" + return ref or "" + + if tool_name in ("read_page", "open_page"): + verb = "Read" if tool_name == "read_page" else "Opened" + line = f"{verb} page: {page_title(args.get('page_id'))}" + if args.get("block_id"): + line += " — one block" + return line + if tool_name == "create_page": + return f"Created page: {args.get('page_title') or ''}".strip() + if tool_name == "copy_page_design": + return f"Copied design from {page_title(args.get('source_page_id'))}" + if tool_name == "generate_page": + return "Building the page" + if tool_name == "preview_page": + return "Screenshot" + if tool_name == "read_block": + return f"Read block: {block_label(args.get('block_id'))}".rstrip(": ") + if tool_name == "query_blocks": + return "Searched blocks" + if tool_name == "set_design_token": + return f"Set --{args['name']}" if args.get("name") else "Set theme variable" + if tool_name == "set_page_script": + return f"Added script: {args.get('name') or ''}".rstrip(": ") + if tool_name == "update_script": + return f"Updated script: {args.get('script_name') or ''}".rstrip(": ") + if tool_name == "create_component": + return f"Created component: {args.get('name') or ''}".strip() + if tool_name in ("get_document", "query_records", "get_doctype_schema"): + return f"Read {args.get('doctype') or 'records'}" + return tool_name.replace("_", " ").capitalize() + + +class AgentRunner: + def __init__( + self, + prompt: str, + model: str, + api_key: str, + *, + user: str | None = None, + page_id: str | None = None, + session_id: str | None = None, + selected_block_ids: list[str] | None = None, + image_url: str | None = None, + registry: ToolRegistry | None = None, + system_prompt: str | None = None, + headless: bool = False, + ): + self.prompt = prompt + self.model = model + self.api_key = api_key + self.user = user or frappe.session.user + self.page_id = page_id + self.session_id = session_id + # Headless = no browser/canvas listening (dashboard chat + fan-out sub-agents): + # no page-YAML streaming, and client tools with a server twin (page scripts) + # apply via their handlers. Block edits are server-applied in BOTH modes. + self.headless = headless or not page_id + # The realtime channel is fixed at construction: focus_page() may change + # self.page_id mid-turn (the agent opening another page), and the chat that + # started the turn must keep receiving events on the channel it subscribed to. + self.channel = page_id or session_id + # The page the user's editor canvas is showing (None headless). When focus + # moves to ANOTHER page mid-turn, client tools with a server twin must apply + # server-side — the canvas can't apply ops for a page it isn't showing. + self.canvas_page_id = page_id if not self.headless else None + self.selected_block_ids = selected_block_ids or [] + self.image_url = image_url + self.registry = registry or build_default_registry() + # The editor-URL prefix is site-configurable; resolve it so the links the + # agent writes (e.g. to a page it built off-canvas) actually work here. + self.system_prompt = (system_prompt or Prompts.AGENT_SYSTEM).replace( + "{BUILDER_PATH}", frappe.conf.builder_path or "builder" + ) + # The authoritative working tree — loaded from the DB by focus_page (in run(), + # or mid-turn when the dashboard agent opens/creates a page). + self.tree: WorkingTree | None = None + # Page locks acquired via focus_page this turn ((key, token) pairs, token-fenced); + # released in run()'s finally. + self.held_locks: list[tuple[str, str]] = [] + # Images a server tool wants shown to the model (e.g. a preview_page screenshot). + # Drained after each round as a follow-up user message — OpenAI-shape tool + # results can't reliably carry image parts through OpenRouter. + self.pending_images: list[dict] = [] + # Live activity feed: one entry per server-tool call, streamed to the chat as + # ai_chat_tool_activity events and persisted on the final message metadata. + self.activity: list[dict] = [] + self.current_activity: dict | None = None + # preview_page calls this turn — hard-capped so a screenshot loop can't run up cost. + self.preview_count = 0 + # Successful WRITE-side server-tool calls this turn (settings, scripts, data, + # page creation…) — counts as real work for the no-op-claim guards. + self.server_mutations = 0 + # Every client op the tree accepted this turn (block edits, scripts, generation). + self.applied_operations: list[dict] = [] + # Revert bookkeeping: pending_state is the focused page's pre-turn state, not yet + # snapshotted; revert_snapshots maps each mutated page to its snapshot doc, so a + # multi-page dashboard turn reverts EVERY page it touched (not just the last). + self.pending_state: dict | None = None + self.revert_snapshots: dict[str, str] = {} + # Per-turn debug trace (one entry per round) + why the turn ended. Persisted on + # the assistant message so the agent debugger can explain what the model did and + # why it stopped (e.g. "model_finished after 1 round, 2 tool calls"). + self.trace: list[dict] = [] + self.stop_reason = "" + # Set once the no-op-claim guard has spent its single corrective round this turn. + self.noop_corrected = False + # Set once the incomplete-build guard has fired (foundation minted but page + # left empty because generate_page was never called). + self.build_correction_used = False + # Debug signals: how many tool-arg blobs needed json_repair, and the finish_reason + # of each LLM call (="length" flags truncation — the usual cause of broken args). + self.args_repaired = 0 + # Tool calls the model emitted as plain-text JSON instead of native calls + # (see parse_text_tool_call) — salvaged, but a signal the model is weak. + self.text_tools_salvaged = 0 + self.finish_reasons: list[str | None] = [] + # Client ops the WorkingTree rejected (bad ref, wrong parent, partial bulk miss). + # Each is fed back to the model to self-correct; also logged and surfaced here so a + # "why didn't my edit land" is traceable in the agent debugger, not just live logs. + self.tool_failures: list[str] = [] + # How many streaming rounds had to be retried after a transient failure this turn. + # Surfaced like args_repaired so a flaky provider shows up in the data, not as a + # silent turn failure. + self.stream_retries = 0 + # Client ops a server tool queued mid-call (run_python mutating the page). + # Drained right after the handler returns — see drain_queued_ops. + self.pending_client_ops: list[dict] = [] + # Tiered model selection: resolved in run() once we know the scenario. + self.loop_model = self.model + # Cache-breakpoint anchors, set by build_messages (see refresh_cache_markers). + self.history_end_index = 0 + self.prompt_index = 0 + # Redis run-lock token for this session's turn (see AISession.start_run). + self.run_token: str | None = None + # Per-turn token tally, summed across every LLM call this turn (the loop's + # tool-calling rounds + the generation stream). Surfaced in debug metadata and + # logged so the selector/tiered-context changes can be measured against baseline. + # `cached_tokens` is the cache-read slice of prompt_tokens (cheap, ~10% price); + # `per_call` keeps each call's split so a turn's cost can be read round by round. + self.usage = { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cached_tokens": 0, + "calls": 0, + # Approximate USD, from the registry's per-1M prices (None-cost calls skipped). + "cost": 0.0, + "per_call": [], + } + + # --- cancellation ----------------------------------------------------- + + def cancel_key(self) -> str | None: + return f"builder_ai_cancel:{self.session_id}" if self.session_id else None + + def is_cancelled(self) -> bool: + key = self.cancel_key() + # use_local_cache=False is critical: the cancel is set by a DIFFERENT + # web worker, and Frappe's per-request local cache would otherwise + # pin the first (miss) result and never re-read Redis. + return bool(frappe.cache.get_value(key, use_local_cache=False)) if key else False + + def clear_cancel_flag(self) -> None: + if key := self.cancel_key(): + frappe.cache.delete_value(key) + + def interruptible_sleep(self, seconds: float) -> None: + """Sleep in small steps so a cancel during retry backoff is honored within ~0.25s + instead of blocking the worker for the full delay.""" + waited = 0.0 + while waited < seconds: + if self.is_cancelled(): + raise CancelledError + step = min(0.25, seconds - waited) + time.sleep(step) + waited += step + + # --- realtime --------------------------------------------------------- + + def emit(self, suffix: str, **kwargs): + # The channel is the page (in-editor chat) or, page-less, the session (dashboard + # chat + sub-agent progress). Fixed at construction — see self.channel. + event = f"{EVENT_PREFIX}_{suffix}" + if self.channel: + event = f"{event}_{self.channel}" + frappe.publish_realtime( + event, {"page_id": self.page_id, "session_id": self.session_id, **kwargs}, user=self.user + ) + + def emit_page(self, suffix: str, **kwargs): + """Emit on the FOCUSED PAGE's channel (regardless of self.channel) so any open + editor acts as a live viewport on a headless build — the user can click through + from the chat and watch the page assemble. origin_page names the page whose + chat is driving this build, so the watching editor can link back to it.""" + if not self.page_id: + return + frappe.publish_realtime( + f"{EVENT_PREFIX}_{suffix}_{self.page_id}", + { + "page_id": self.page_id, + "session_id": self.session_id, + "origin_page": self.canvas_page_id, + **kwargs, + }, + user=self.user, + ) + + def ensure_revert_snapshot(self) -> None: + """Snapshot the focused page's pre-turn state the first time the turn mutates it + — before the mutation lands, so even a cancelled multi-round edit stays + revertable. One snapshot per focused page per turn (focus_page arms the next).""" + if self.pending_state is None or not self.page_id: + return + state, self.pending_state = self.pending_state, None + if snapshot := save_revert_snapshot(self.page_id, state): + self.revert_snapshots[self.page_id] = snapshot + + @staticmethod + def cached_prompt_tokens(usage) -> int: + """The cache-read slice of prompt tokens, across provider shapes: OpenAI/litellm + put it under prompt_tokens_details.cached_tokens; Anthropic exposes + cache_read_input_tokens. 0 when the provider reports neither.""" + details = getattr(usage, "prompt_tokens_details", None) + if details and (cached := getattr(details, "cached_tokens", None)): + return cached + return getattr(usage, "cache_read_input_tokens", 0) or 0 + + def record_usage(self, chunk, model: str | None = None) -> None: + """Add a streamed chunk's usage to the per-turn tally. Only the final chunk of + a stream (stream_options.include_usage) carries usage; the rest are None. + `model` is the model that produced this stream (the generation stream runs on + self.model; loop rounds on self.loop_model) — it prices the call.""" + usage = getattr(chunk, "usage", None) + if not usage: + return + prompt = getattr(usage, "prompt_tokens", 0) or 0 + completion = getattr(usage, "completion_tokens", 0) or 0 + total = getattr(usage, "total_tokens", 0) or 0 + cached = self.cached_prompt_tokens(usage) + cost = ModelRegistry.estimate_cost(model or self.loop_model, prompt, completion, cached) + self.usage["prompt_tokens"] += prompt + self.usage["completion_tokens"] += completion + self.usage["total_tokens"] += total + self.usage["cached_tokens"] += cached + self.usage["calls"] += 1 + if cost is not None: + self.usage["cost"] = round((self.usage.get("cost") or 0) + cost, 6) + self.usage["per_call"].append( + {"prompt": prompt, "completion": completion, "cached": cached, "cost": cost} + ) + + def record_round(self, round_index: int, tool_operations: list[dict], text: str) -> None: + """Append one round to the debug trace: which tools the model called (with + truncated args) and any text it wrote. This is what the agent debugger reads to + explain a turn — e.g. why it applied only N blocks.""" + self.trace.append( + { + "round": round_index, + "tools": [ + { + "name": op["tool_name"], + "args": BlockCodec.truncate_for_log( + json.dumps(op.get("args", {}), ensure_ascii=False), 300 + ), + } + for op in tool_operations + ], + "text": BlockCodec.truncate_for_log(text or "", 300), + } + ) + + # --- message construction -------------------------------------------- + + def build_page_context(self) -> str: + return render_page_context(self.page_root(), self.selected_block_ids) + + def build_site_context(self) -> str: + """A compact inventory of the site's pages, so the agent starts every turn + knowing what exists instead of spending a query_records round-trip (or + guessing) to find out.""" + rows = frappe.get_all( + "Builder Page", + fields=["name", "route", "page_title", "published", "project_folder"], + order_by="modified desc", + limit=100, + ) + if not rows: + return "This site has no pages yet." + lines = [ + f"- {r.name} | /{(r.route or '').lstrip('/')} | {r.page_title or ''}" + f" | {'published' if r.published else 'draft'}" + + (f" | folder: {r.project_folder}" if r.project_folder else "") + for r in rows + ] + return "Pages on this site (id | route | title | status):\n" + "\n".join(lines) + + def build_memory_context(self) -> str: + """Facts the agent saved in past conversations (see tools/memory.py) — part of + the cached context block, so remembering costs nothing per-round.""" + from builder.ai.agent.tools.memory import memory_context + + return memory_context() + + def build_messages(self) -> list[dict]: + messages: list[dict] = [{"role": "system", "content": self.system_prompt}] + + # Prior conversation FIRST, as proper role-tagged turns: old turns replay + # byte-stable from the session rows, so system + history stays a provider- + # cache prefix hit ACROSS turns. The page context goes after — it changes + # every turn and would otherwise invalidate everything behind it. + messages.extend(AISession.build_context_messages_from_id(self.session_id)) + self.history_end_index = len(messages) - 1 + + # The page structure plus the site inventory (page-less turns get just the + # inventory). It's resent on every round of a multi-round turn, so a cache + # marker on the prompt right after it cuts both latency and input cost + # across the loop. The inventory is what lets the agent act on OTHER pages + # (read_page/open_page/manage_pages) without spending a discovery round-trip. + page_context = self.build_page_context() + site_context = self.build_site_context() + context = "\n\n".join(filter(None, [page_context, site_context, self.build_memory_context()])) + if context: + messages.append({"role": "user", "content": context}) + messages.append({"role": "assistant", "content": "Understood. I have the current context."}) + + user_text = self.prompt + if self.selected_block_ids: + user_text += f"\n\n(User has selected: {', '.join(self.selected_block_ids)})" + if self.image_url: + messages.append( + { + "role": "user", + "content": [ + {"type": "text", "text": user_text}, + {"type": "image_url", "image_url": {"url": self.image_url}}, + ], + } + ) + else: + messages.append({"role": "user", "content": user_text}) + self.prompt_index = len(messages) - 1 + return messages + + def refresh_cache_markers(self, messages: list[dict]) -> None: + """Re-derive the prompt-cache breakpoints before every LLM round (Claude + routes only benefit; llm.py strips the markers for other providers). + Deterministic positions: the system prompt (1h TTL), the end of the replayed + history (the prefix next turn's first request re-matches), the current user + prompt (the stable turn-start prefix), the newest message (caches this + round's prefix for the next), and a mid-turn anchor every + MID_TURN_MARKER_EVERY messages so a long turn's rounds stay inside + Anthropic's cache-lookback window. Capped at 4 markers, oldest dropped + first — their cache entries were already written by earlier rounds.""" + for m in messages: + m.pop("cache_control", None) + if isinstance(m.get("content"), list): + for block in m["content"]: + if isinstance(block, dict): + block.pop("cache_control", None) + last = len(messages) - 1 + positions = {0, max(self.history_end_index, 0), self.prompt_index, last} + span = last - self.prompt_index + if span > MID_TURN_MARKER_EVERY + 3: + anchor = self.prompt_index + MID_TURN_MARKER_EVERY * (span // MID_TURN_MARKER_EVERY) + positions.add(min(anchor, last)) + for pos in sorted(positions)[-MAX_CACHE_MARKERS:]: + messages[pos]["cache_control"] = SYSTEM_CACHE_CONTROL if pos == 0 else TURN_CACHE_CONTROL + + # --- LLM call --------------------------------------------------------- + + def call_tool_llm(self, messages: list[dict]) -> tuple[list[dict], str, list[dict]]: + """Stream one tool-calling round, retrying the WHOLE round on a transient stream + failure (network drop, 429, 5xx, mid-stream reset). Safe because a round applies + nothing until it returns — ops are emitted and `messages` mutated by the caller only + after this returns, so a failed attempt leaves no partial state; we just re-issue the + identical completion (the cached prefix makes the retry cheap). litellm can't fall + back mid-stream (fallbacks are off while streaming), so this is the retry layer.""" + for attempt in range(STREAM_MAX_ATTEMPTS): + try: + return self.stream_tool_round(messages) + except CancelledError: + raise + except Exception as exc: + if attempt == STREAM_MAX_ATTEMPTS - 1 or not llm.is_retryable(exc): + raise + self.stream_retries += 1 + backoff = STREAM_BACKOFF_BASE * (2**attempt) + logger.warning( + "Stream round failed (attempt %d/%d): %s — retrying in %.1fs", + attempt + 1, + STREAM_MAX_ATTEMPTS, + exc, + backoff, + ) + self.interruptible_sleep(backoff) + + def stream_tool_round(self, messages: list[dict]) -> tuple[list[dict], str, list[dict]]: + """Stream one tool-calling completion. Returns (tool_operations, + text_content, raw_tool_calls). Side-effect-free until it returns (see + call_tool_llm) — accumulates into locals only, so it is safe to re-run. + + Tool-call arguments are accumulated by index across chunks. + `raw_tool_calls` reconstruct the assistant turn for a follow-up round. + Large artifacts (e.g. a full page) are NOT streamed here — the model + calls an artifact tool with a short brief, and the loop hands generation + to that tool's generator, which streams the artifact as content. + """ + stream = llm.complete_with_tools( + self.loop_model, + messages, + self.registry.schemas(), + llm.TASK_PARAMS["agent"], + api_key=self.api_key, + stream=True, + ) + + content_parts: list[str] = [] + # index -> {"id", "name", "args"}; preserves call order across chunks. + acc: dict[int, dict] = {} + finish_reason = None + + for chunk in stream: + if self.is_cancelled(): + try: + stream.close() + except Exception: + pass + raise CancelledError + self.record_usage(chunk) + # The final include_usage chunk carries usage but no choices. + if not chunk.choices: + continue + if fr := chunk.choices[0].finish_reason: + finish_reason = fr + delta = chunk.choices[0].delta + if getattr(delta, "content", None): + content_parts.append(delta.content) + for tc in getattr(delta, "tool_calls", None) or []: + idx = tc.index if tc.index is not None else 0 + entry = acc.setdefault(idx, {"id": None, "name": None, "args": ""}) + if tc.id: + entry["id"] = tc.id + fn = getattr(tc, "function", None) + if fn and fn.name: + entry["name"] = fn.name + if fn and fn.arguments: + entry["args"] += fn.arguments + + tool_operations: list[dict] = [] + raw_tool_calls: list[dict] = [] + for idx in sorted(acc): + entry = acc[idx] + if not entry["name"]: + continue + raw_arguments = entry["args"] or "" + parsed, repaired = llm.loads_tolerant(raw_arguments) + truncated_args = BlockCodec.truncate_for_log(raw_arguments, 2000) + if parsed is None: + # Even tolerant parsing failed — don't silently drop to {} with no trace + # (that surfaces as an empty plan/edit). Log it loudly. + args = {} + logger.warning( + "AI tool args UNPARSEABLE (tool=%s): %s", + entry["name"], + truncated_args, + ) + else: + args = parsed if isinstance(parsed, dict) else {} + if repaired: + self.args_repaired += 1 + logger.warning( + "AI tool args recovered via json_repair (tool=%s): %s", + entry["name"], + truncated_args, + ) + logger.info( + "AI tool response: tool=%s, repaired=%s, raw_arguments=%s", + entry["name"], + repaired, + truncated_args, + ) + tool_operations.append({"tool_name": entry["name"], "args": args}) + raw_tool_calls.append( + { + "id": entry["id"], + "type": "function", + "function": {"name": entry["name"], "arguments": raw_arguments}, + } + ) + + content = "".join(content_parts) + if not tool_operations and (salvaged := parse_text_tool_call(content, self.registry.names())): + name, args, prose = salvaged + self.text_tools_salvaged += 1 + logger.warning( + "AI tool call emitted as TEXT, salvaged (tool=%s): %s", + name, + BlockCodec.truncate_for_log(content, 300), + ) + tool_operations.append({"tool_name": name, "args": args}) + raw_tool_calls.append( + { + "id": f"call_text_salvage_{len(self.trace)}", + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + } + ) + content = prose + self.finish_reasons.append(finish_reason) + # finish_reason="length" means the model hit max_tokens mid-output — the usual + # cause of truncated/unparseable tool args. Surface it as the prime suspect. + if finish_reason == "length": + logger.warning("Agent LLM hit max_tokens (finish_reason=length) — tool args may be truncated") + logger.info( + "Agent LLM responded: tool_calls=%d, has_text=%s, finish_reason=%s", + len(tool_operations), + bool(content), + finish_reason, + ) + return tool_operations, content, raw_tool_calls + + def queue_client_op(self, op: dict) -> None: + """Called by a server tool mid-handler to emit a client op (run_python queues + the mutated page tree here). Drained by the loop right after the handler.""" + self.pending_client_ops.append(op) + + def drain_queued_ops(self) -> list[dict]: + """Snapshot, sync the working tree, persist, and emit ops a server tool queued + mid-handler (run_python queues the mutated page as set_page_blocks), so the + canvas updates live and a later run_python sees the tree it already mutated.""" + from builder.ai import page_writer + + ops, self.pending_client_ops = self.pending_client_ops, [] + if not ops: + return [] + if any(op["tool_name"] in SNAPSHOT_TOOLS for op in ops): + self.ensure_revert_snapshot() + for op in ops: + if op["tool_name"] == "set_page_blocks": + # run_python lets the model hand-build component instances; repair + # childless ones or they render as nothing (editor + published alike). + op["args"]["blocks"] = page_writer.normalize_component_instances(op["args"]["blocks"]) + self.tree.root = op["args"]["blocks"] + self.applied_operations.extend(ops) + self.emit("tool_batch", operations=ops) + if self.channel != self.page_id: + self.emit_page("tool_batch", operations=ops) + if self.page_id and self.tree and self.tree.root: + page_writer.save_draft_blocks(self.page_id, self.tree.root) + return ops + + def page_root(self) -> dict | None: + """The current page's root block — the authoritative working tree. Edits made + this turn are visible to context rebuilds and the query tools, and refs stay + valid across rounds.""" + return self.tree.root if self.tree else None + + def focus_page(self, page_id: str, *, lock: bool = True) -> str: + """Point the turn at a page: load its blocks from the DB into the working tree + (context, query tools, and block edits all read/write it), and capture the + pre-edit state so the turn stays revertable. With lock=True the page lock is + held for the rest of the turn so parallel AI tasks can't fight over one page + (sub-agents pass lock=False — their task runner already holds it). + Returns the rendered page context — the tool result for open_page/create_page.""" + from builder.ai import page_writer + + key = locks.page_key(page_id) + if lock and key not in (k for k, _ in self.held_locks): + token = locks.acquire(key, locks.PAGE_LOCK_TTL) + if token is None: + return ( + f"FAILED: page {page_id} is being edited by another AI task right now — " + "try again in a moment." + ) + self.held_locks.append((key, token)) + root = page_writer.load_page_root(page_id) + # A focus move away from the page the user is looking at must be VISIBLE — + # ops there won't show on their canvas, and a silent switch reads as the + # agent lying about what it built. + if self.page_id and page_id != self.page_id: + title = frappe.db.get_value("Builder Page", page_id, "page_title") or page_id + self.emit("progress", message=f"Now editing another page: '{title}'") + # Let the origin editor's pill survive navigation/reload: record that this + # chat's run is working on an off-canvas page (cleared when the run ends). + if not self.headless and self.canvas_page_id and page_id != self.canvas_page_id: + frappe.cache().set_value( + f"builder_ai_offpage_build:{self.canvas_page_id}", + {"target": page_id, "session_id": self.session_id}, + expires_in_sec=locks.PAGE_LOCK_TTL, + ) + self.page_id = page_id + self.tree = WorkingTree(root) + # Arm the revert snapshot — unless this page was already snapshotted this turn + # (a refocus must keep its original pre-turn state). + self.pending_state = None if page_id in self.revert_snapshots else capture_page_state(page_id) + if root is None: + return f"Opened page {page_id} — it is empty. Build it with generate_page." + return f"Opened page {page_id}.\n{render_page_context(root, self.selected_block_ids)}" + + # --- live activity feed ------------------------------------------------ + + def begin_activity(self, tool_name: str, args: dict) -> dict | None: + entry = None + if tool_name not in ACTIVITY_SILENT: + entry = { + "id": len(self.activity), + "tool": tool_name, + "summary": activity_summary(tool_name, args, self.tree), + "status": "running", + } + # Working-page tools carry the page id so the chat can offer an "Open" + # link on the line (create_page fills it in from its handler). + if tool_name in ("open_page", "generate_page", "preview_page", "copy_page_design"): + entry["page"] = (args or {}).get("page_id") or self.page_id + self.activity.append(entry) + self.current_activity = entry + self.emit("tool_activity", **entry) + return entry + + def end_activity(self, entry: dict | None) -> None: + if entry is None: + return + entry["status"] = "done" + self.current_activity = None + self.emit("tool_activity", **entry) + + @staticmethod + def describe_operations(operations: list[dict]) -> str: + """A deterministic one-line summary of applied ops — used when the model + didn't return its own summary text, so we avoid a second LLM round trip.""" + from collections import Counter + + counts = Counter(op.get("tool_name") for op in operations) + + def blk(n: int) -> str: + return "block" if n == 1 else "blocks" + + # update_blocks edits many blocks in one op — count the blocks it touched, + # not the single call, so the summary reads "updated 12 blocks" not "1". + batched = 0 + for op in operations: + if op.get("tool_name") != "update_blocks": + continue + args = op.get("args") or {} + patches = args.get("patches") + batched += len(patches) if isinstance(patches, list) else len(args.get("block_ids") or []) + + parts: list[str] = [] + if n := counts.get("add_block"): + parts.append(f"added {n} {blk(n)}") + if n := (counts.get("update_block", 0) + batched): + parts.append(f"updated {n} {blk(n)}") + if n := counts.get("remove_block"): + parts.append(f"removed {n} {blk(n)}") + if n := counts.get("move_block"): + parts.append(f"moved {n} {blk(n)}") + if counts.get("set_page_blocks"): + parts.append("updated the page") + if counts.get("set_page_script"): + parts.append("added a script") + if counts.get("update_script"): + parts.append("updated a script") + + if not parts: + n = len(operations) + return f"Applied {n} change{'s' if n != 1 else ''} to the page." + sentence = parts[0] if len(parts) == 1 else f"{', '.join(parts[:-1])} and {parts[-1]}" + return sentence[0].upper() + sentence[1:] + "." + + # --- round execution ---------------------------------------------------- + + def op_kind(self, op: dict) -> str: + """How the loop must handle one tool call: "artifact" (streamed generation), + "terminal" (ends the turn), "server" (run the handler now), or "client" + (apply to the working tree + mirror to the canvas). A client tool with a + server twin (page scripts) runs as a server op when there is no canvas to + apply it — headless, or the agent focused a page the canvas isn't showing.""" + tool = self.registry.get(op["tool_name"]) + if tool and tool.artifact: + return "artifact" + side = tool.side if tool else "client" + off_canvas = self.headless or self.page_id != self.canvas_page_id + if side == "client" and tool and tool.handler: + if off_canvas or op["tool_name"] in SCRIPT_TWIN_TOOLS: + return "server" + return side + + def apply_client_ops(self, ops: list[dict]) -> tuple[dict[int, str], list[dict]]: + """Apply this round's client ops to the working tree — the source of truth — + then mirror the ACCEPTED ones to the canvas and persist the draft. Rejected + ops are never emitted, so the canvas can't apply an edit the server refused. + Returns (tool-result per op, accepted ops).""" + results: dict[int, str] = {} + applied: list[dict] = [] + for op in ops: + if op["tool_name"] in SNAPSHOT_TOOLS: + self.ensure_revert_snapshot() + content = self.tree.apply(op["tool_name"], op["args"]) + results[id(op)] = content + # "FAILED" (hard miss) or "NOT FOUND" (partial bulk miss) — a correction + # the model is now being asked to make. Record + log so it's not invisible. + if "FAILED" in content or "NOT FOUND" in content: + self.tool_failures.append(f"{op['tool_name']}: {content}") + logger.warning("Client op rejected — %s: %s", op["tool_name"], content) + if not content.startswith("FAILED"): + applied.append(op) + if applied: + self.applied_operations.extend(applied) + self.emit("tool_batch", operations=applied) + # When the focused page isn't the channel's page (headless session chat, or + # an editor agent that opened another page), mirror accepted ops to the + # focused page's channel too, so an editor open on it updates live. + if self.channel != self.page_id: + self.emit_page("tool_batch", operations=applied) + if self.page_id and self.tree.root: + from builder.ai import page_writer + + # Persist after every applied round so a cancel/crash keeps the work + # done so far (the same live-apply semantics the canvas shows). + page_writer.save_draft_blocks(self.page_id, self.tree.root) + return results, applied + + def run_op(self, op: dict, client_results: dict[int, str]) -> str | None: + """Produce one tool call's result string. Client ops were already applied by + apply_client_ops; server ops run their handler here. Returns None when a + terminal tool ended the turn (its handler emitted the card and persisted it).""" + kind = self.op_kind(op) + if kind == "client": + return client_results[id(op)] + tool = self.registry.get(op["tool_name"]) + if kind == "terminal": + # A terminal handler may DECLINE by returning a string (e.g. "that DocType + # already exists") — the reason goes back as a tool result and the loop + # continues. None = the card was emitted and the turn is over. + return self.handle_terminal(op) + if kind == "artifact": + # Generation is a STEP of the turn in both modes: the generator streams + # YAML live (canvas preview in the editor), persists the page, and the + # loop continues — so the model can add scripts, verify, and refine in + # the same turn. + content, ops = self.run_generation_step(tool, op) + self.applied_operations.extend(ops) + if ops: + # The authoritative op replaces the throwaway streamed preview with + # the server's block tree (shared ids). + self.emit("tool_batch", operations=ops) + if self.channel != self.page_id: + # Mirror to any editor watching the focused page (headless build, + # or an editor agent that opened another page); the complete + # resets that watcher's "working" state. + self.emit_page("tool_batch", operations=ops) + self.emit_page("complete", message="Page generated — the agent may keep refining it.") + return content + entry = self.begin_activity(op["tool_name"], op["args"]) + if op["tool_name"] in SNAPSHOT_TOOLS: + self.ensure_revert_snapshot() + content = tool.handler(self, op["args"]) + self.end_activity(entry) + self.drain_queued_ops() + if op["tool_name"] not in READ_ONLY_SERVER_TOOLS and not str(content).startswith("FAILED"): + self.server_mutations += 1 + if op["tool_name"] in SCRIPT_TWIN_TOOLS and not self.headless: + # Mirror the server-applied script op so the open editor updates its + # script list / undo tracking — flagged so the canvas does NO DB work. + op["args"]["server_applied"] = True + self.applied_operations.append(op) + self.emit("tool_batch", operations=[op]) + if self.channel != self.page_id: + self.emit_page("tool_batch", operations=[op]) + return content + + def emit_round_note(self, text: str, applied: list[dict]) -> None: + """Live narration: surface what the model said / did this round, so a long + multi-round turn shows real progress instead of a frozen "Applying…".""" + note = (text or "").strip() + if looks_like_tool_syntax(note): + note = "" + if not note and applied: + note = self.describe_operations(applied) + if note: + self.emit("progress", message=note) + + def correction_for(self, summary_text: str) -> str | None: + """A no-tool round that should have been a tool call gets EXACTLY ONE + corrective round. Three shapes: card markup written as plain text (mimicking + the persisted replay format — renders no controls), a multi-option question + asked as prose bullets (same dead end, no markup to match), and a summary + that CLAIMS an edit when nothing was applied this turn (hallucinated + success).""" + # Incomplete build: the model minted the design system (tokens/scripts) but + # never called generate_page, so the page is still empty. Its own dedicated + # one-shot correction, independent of the no-op guard (this turn DID mutate). + if not self.build_correction_used and self.build_incomplete(): + self.build_correction_used = True + self.stop_reason = "build_retry" + logger.warning( + "Incomplete build corrected: foundation set but page still empty (no generate_page)" + ) + return BUILD_INCOMPLETE_CORRECTION + if self.noop_corrected: + return None + correction = None + if looks_like_card_text(summary_text) or looks_like_json_card(summary_text): + correction = CARD_CORRECTION + elif asks_options_as_text(summary_text): + correction = OPTIONS_AS_TEXT_CORRECTION + elif ( + not self.applied_operations and not self.server_mutations and claims_unbacked_action(summary_text) + ): + correction = NOOP_CORRECTION + if correction is None: + return None + self.noop_corrected = True + self.stop_reason = "noop_retry" + kind = { + id(CARD_CORRECTION): "card-as-text", + id(OPTIONS_AS_TEXT_CORRECTION): "options-as-text", + id(NOOP_CORRECTION): "no-op claim", + }[id(correction)] + logger.warning( + "No-tool round corrected (%s): %s", + kind, + BlockCodec.truncate_for_log(summary_text, 300), + ) + return correction + + def build_incomplete(self) -> bool: + """True when this turn laid the FOUNDATION (minted design tokens) but never + called generate_page, leaving the page empty — the model stopped mid-build. + Only meaningful on a page turn; the fix is one more round that generates.""" + if not self.page_id: + return False + root = self.page_root() + if root and (root.get("children") or []): + return False # the page has content — build reached the layout + tools_called = {t.get("name") for entry in self.trace for t in entry.get("tools") or []} + return "set_design_token" in tools_called and "generate_page" not in tools_called + + def flush_pending_images(self, messages: list[dict]) -> None: + """Images a tool captured this round (preview_page screenshots) ride a + follow-up user message — appended only after every role:"tool" result, + as the OpenAI message shape requires.""" + for img in self.pending_images: + messages.append( + { + "role": "user", + "content": [ + {"type": "text", "text": img["caption"]}, + {"type": "image_url", "image_url": {"url": img["data_url"]}}, + ], + } + ) + self.pending_images.clear() + + # --- orchestration ---------------------------------------------------- + + def emit_cancelled(self) -> None: + msg = "Cancelled." + AISession.try_append_message( + self.session_id, "assistant", msg, message_type="status", metadata={"status": "cancelled"} + ) + frappe.db.commit() + self.emit("complete", message=msg) + + def fail_turn(self, message: str) -> None: + """End the turn with a persisted error message + error event.""" + AISession.try_append_message( + self.session_id, "assistant", message, message_type="status", metadata={"status": "error"} + ) + frappe.db.commit() # commit before emit so the client's reload sees it + self.emit("error", message=message) + + def run(self): + # Clear any stale cancel flag from a previous turn before starting. + self.clear_cancel_flag() + started = time.monotonic() + logger.info( + f"AgentRunner.run: page_id={self.page_id}, model={self.model}, " + f"session_id={self.session_id}, user={self.user}" + ) + + # One turn per session at a time — an atomic Redis lock with a TTL, so a + # crashed worker can never brick the session (the old is_running DB flag did). + if self.session_id: + self.run_token = AISession.start_run(self.session_id) + if self.run_token is None: + logger.warning(f"AgentRunner.run: session {self.session_id} already running, rejecting") + self.emit( + "error", message="Another AI request is still processing. Please wait for it to finish." + ) + return + + try: + self.run_turn(started) + finally: + self.clear_cancel_flag() + if self.canvas_page_id: + frappe.cache().delete_value(f"builder_ai_offpage_build:{self.canvas_page_id}") + for key, token in self.held_locks: + locks.release(key, token) + self.held_locks = [] + if self.session_id: + AISession.end_run(self.session_id, self.run_token) + + def run_turn(self, started: float): + # Load the page into the authoritative working tree. Editor turns take the + # page lock (a dashboard task could otherwise edit the same page mid-turn); + # sub-agents arrive with the lock already held by their task runner. + if self.page_id and self.tree is None: + focused = self.focus_page(self.page_id, lock=not self.headless) + if focused.startswith("FAILED"): + self.fail_turn(focused) + return + if self.tree is None: + self.tree = WorkingTree(None) + + # Editing an existing page runs the loop on the user's CHOSEN model — edit + # taste matters as much as generation taste, and silently downgrading a + # deliberately-picked heavy model is the surest way to degrade output. + # Headless turns (dashboard orchestrator + sub-agents) always use the chosen + # model too. Only the editor's lightweight empty-page conversation + # (clarify/plan) drops to the cheap model. + has_content = self.page_root() is not None + self.loop_model = ( + self.model if (has_content or self.headless) else ModelRegistry.get_simple(self.model) + ) + label = ModelRegistry.get_label(self.loop_model) + self.emit("progress", message=f"Thinking with {label}" if label else "Thinking…") + + messages = self.build_messages() + summary_text = "" + + try: + for round_index in range(MAX_ROUNDS): + self.refresh_cache_markers(messages) + tool_operations, summary_text, raw_tool_calls = self.call_tool_llm(messages) + self.record_round(round_index, tool_operations, summary_text) + + if not tool_operations: + if correction := self.correction_for(summary_text): + messages.append({"role": "assistant", "content": summary_text}) + messages.append({"role": "user", "content": correction}) + continue + self.stop_reason = "model_finished" + break + + # Apply block/script ops FIRST — the canvas updates live, and a terminal + # tool in the same round can no longer silently discard them. + client_ops = [op for op in tool_operations if self.op_kind(op) == "client"] + client_results, applied = self.apply_client_ops(client_ops) + + has_terminal = any(self.op_kind(op) == "terminal" for op in tool_operations) + if not has_terminal: + self.emit_round_note(summary_text, applied) + + messages.append( + {"role": "assistant", "content": summary_text or None, "tool_calls": raw_tool_calls} + ) + turn_over = False + for tc, op in zip(raw_tool_calls, tool_operations, strict=True): + content = self.run_op(op, client_results) + if content is None: + turn_over = True # terminal card emitted + persisted + break + messages.append({"role": "tool", "tool_call_id": tc["id"], "content": content}) + if turn_over: + return + self.flush_pending_images(messages) + else: + # Loop ran the full MAX_ROUNDS without the model finishing — a very large + # bulk edit or a stuck loop. The work done so far still applies. + self.stop_reason = "max_rounds" + + except CancelledError: + self.emit_cancelled() + return + except Exception as e: + logger.error(f"Agent LLM call failed: {e!s}", exc_info=True) + frappe.log_error(f"Agent LLM call failed: {e}", "AgentRunner.run") + # Show a generic message to the user — raw provider/exception strings can + # leak internals (keys, model ids, stack detail). Full error is logged above. + self.fail_turn("Something went wrong while building your changes. Please try again.") + return + + self.finish_turn(summary_text, started) + + def finish_turn(self, summary_text: str, started: float): + """Wrap up a completed loop: recover stray output, guard hallucinated + summaries, pick/emit the final summary, and persist the turn.""" + # Defensive: a weaker model may emit page YAML as content instead of calling + # generate_page. Persist it server-side and apply it as a generation op. + if not self.applied_operations and self.page_id and looks_like_page_yaml(summary_text): + logger.info("Recovering YAML-as-content into a synthetic generate_page op") + from builder.ai import page_writer + + self.ensure_revert_snapshot() + root, data_script = page_writer.persist_page(self.page_id, BlockCodec.strip_fences(summary_text)) + if root: + op = {"tool_name": "generate_page", "args": {"blocks": [root], "data_script": data_script}} + self.applied_operations.append(op) + self.emit("tool_batch", operations=[op]) + summary_text = "" + + # Leaked tool-call syntax (or a JSON card that survived its corrective round) + # is never a summary — suppress it. With applied work the deterministic + # fallbacks below take over; with none, say what happened. + if looks_like_tool_syntax(summary_text) or looks_like_json_card(summary_text): + logger.warning( + "Suppressed tool-syntax leak in summary: %s", BlockCodec.truncate_for_log(summary_text, 300) + ) + summary_text = ( + "" + if self.applied_operations + else "My last step came out garbled and was not applied — ask me to try that again." + ) + + if not self.applied_operations and not summary_text: + # A soft miss, not a failure: the model may have done real tool work (reads) + # and just failed to write its reply. Warn — and persist, so the turn doesn't + # vanish on reload. + logger.warning("Agent returned empty response (no text; activity=%d)", len(self.activity)) + if self.server_mutations: + note = "Done — the steps above were applied (I skipped the write-up)." + elif self.activity: + note = ( + "I gathered that information but didn't write up a reply — ask me again and I'll answer." + ) + else: + note = "I came back empty on that one — try rephrasing your request." + metadata = {"status": "warning"} + if self.activity: + metadata["activity"] = self.activity + AISession.try_append_message( + self.session_id, "assistant", note, message_type="status", metadata=metadata + ) + frappe.db.commit() + self.emit("error", message=note, warning=True) + return + + # Backstop: the model still claims an edit it never made (no ops applied, no + # server-side writes, even after the corrective round). Don't present a + # hallucinated success — say so. + if not self.applied_operations and not self.server_mutations and claims_unbacked_action(summary_text): + logger.warning( + "Unbacked action claim persisted (no ops applied): %s", + BlockCodec.truncate_for_log(summary_text, 300), + ) + summary_text = ( + "I described that change but didn't actually apply it — so nothing on the page " + "changed. Could you rephrase, or tell me more specifically what to change?" + ) + self.stop_reason = self.stop_reason or "noop_unbacked" + + # Block/script edits and generation ops were already emitted incrementally inside + # the loop (live canvas progress); nothing more to emit here. + generated = any(op["tool_name"] == "generate_page" for op in self.applied_operations) + if summary_text: + # The model wrote a summary alongside its tool calls — richest, and + # free (no extra round trip). Prefer it whenever present. + self.emit("stream", chunk=summary_text) + elif generated: + # Skip a summary call after generation (the YAML arg would bloat its + # context); send a fixed nudge instead. + summary_text = ( + "Created the page. Ask me to refine it — adjust styles, add sections, or change the layout." + ) + self.emit("stream", chunk=summary_text) + else: + # Block/script edits with no model text: synthesise the summary from + # the ops rather than making a second LLM call. The canvas already + # updated from the tool_batch above; this just ends the turn sooner. + summary_text = self.describe_operations(self.applied_operations) + self.emit("stream", chunk=summary_text) + + # The turn built/edited a DIFFERENT page than the one on the user's canvas: + # the editor link is their only path to the result, and models (especially + # cheap ones) skip the prompt rule — append it deterministically. + if ( + not self.headless + and self.applied_operations + and self.page_id + and self.canvas_page_id + and self.page_id != self.canvas_page_id + and f"/page/{self.page_id}" not in summary_text + ): + title = frappe.db.get_value("Builder Page", self.page_id, "page_title") or self.page_id + builder_path = frappe.conf.builder_path or "builder" + link_note = f"\n\nOpen the page: [{title}](/{builder_path}/page/{self.page_id})" + summary_text += link_note + self.emit("stream", chunk=link_note) + + # Hit the per-turn round cap → the work is INCOMPLETE. Say so, so a big edit + # doesn't look finished; the user can reply "continue" to resume from here. + if self.stop_reason == "max_rounds": + hint = '\n\n⚠️ I hit my edit-step limit for one turn before finishing — reply "continue" and I\'ll pick up where I left off.' + summary_text += hint + self.emit("stream", chunk=hint) + + elapsed_ms = round((time.monotonic() - started) * 1000) + logger.info( + "AI turn done | page=%s rounds=%d llm_calls=%d prompt_tokens=%d " + "cached_tokens=%d completion_tokens=%d total_tokens=%d tool_failures=%d " + "stream_retries=%d elapsed_ms=%d stop=%s", + self.page_id, + len(self.trace), + self.usage["calls"], + self.usage["prompt_tokens"], + self.usage["cached_tokens"], + self.usage["completion_tokens"], + self.usage["total_tokens"], + len(self.tool_failures), + self.stream_retries, + elapsed_ms, + self.stop_reason or "model_finished", + ) + final_metadata = { + "status": "complete", + "model": self.model, + "operations": len(self.applied_operations), + # Trace for the agent debugger: why the turn ended + what the model did each + # round. Explains cases like "only 2 blocks updated" at a glance. + "debug": { + "stopReason": self.stop_reason or "model_finished", + "loopModel": self.loop_model, + "rounds": len(self.trace), + "noopCorrected": self.noop_corrected, + "argsRepaired": self.args_repaired, + "textToolsSalvaged": self.text_tools_salvaged, + "finishReasons": self.finish_reasons, + "toolFailures": self.tool_failures, + "streamRetries": self.stream_retries, + # Per-turn cost signal for the selector/tiered-context experiment. + "tokens": self.usage, + # How much room the conversation has: the loop model's window; the + # latest call's prompt_tokens (per_call) is the current context size. + "contextWindow": ModelRegistry.context_window(self.loop_model), + "elapsedMs": elapsed_ms, + "trace": self.trace, + }, + } + # Revert handles: one snapshot per page the turn mutated. revertSnapshot (the + # most recent) keeps the existing frontend contract; revertSnapshots carries + # the full set so a multi-page dashboard turn reverts every page it touched. + if self.revert_snapshots: + final_metadata["revertSnapshot"] = next(reversed(self.revert_snapshots.values())) + if len(self.revert_snapshots) > 1: + final_metadata["revertSnapshots"] = self.revert_snapshots + if self.activity: + # The chat's activity feed (tool lines + screenshots) — rendered live from + # tool_activity events, rehydrated from here on a session reload. + final_metadata["activity"] = self.activity + AISession.try_append_message( + self.session_id, + "assistant", + summary_text or f"Applying {len(self.applied_operations)} change(s).", + message_type="chat", + task_type="agent", + metadata=final_metadata, + ) + self.maybe_name_session() + frappe.db.commit() # commit before emit so the client's reload sees the final turn + self.emit("complete", message=summary_text or "Done") + + def maybe_name_session(self) -> None: + """The first completed turn names the chat: a short generated title reads + better in the session switcher than the raw first prompt ("Collection page + for The Pieces" vs "Create a collection page and link it here").""" + if not self.session_id or frappe.db.get_value(AISession.DOCTYPE, self.session_id, "title"): + return + first = frappe.db.get_value( + AISession.MESSAGE_DOCTYPE, + {"session": self.session_id, "role": "user"}, + "content", + order_by="creation asc", + ) + if not first: + return + try: + title = llm.complete( + ModelRegistry.get_simple(self.model), + [ + { + "role": "user", + "content": ( + "Name this website-builder chat in 2-5 words — what it's about, not what was " + "asked. Title case, no quotes, no trailing punctuation. Reply with the title " + f"only.\nFirst message: {first[:400]}" + ), + } + ], + {"max_tokens": 24, "temperature": 0.3}, + stream=False, + api_key=self.api_key, + ) + title = (title or "").strip().strip("\"'.").strip() + if 0 < len(title) <= 60 and "\n" not in title: + frappe.db.set_value(AISession.DOCTYPE, self.session_id, "title", title, update_modified=False) + except Exception as e: + logger.warning("session title generation skipped: %s", e) + + def handle_terminal(self, op: dict) -> str | None: + """Run a terminal tool's handler (which emits the appropriate event and + persists the message). Returns the handler's return value: None = the turn + is over (question/plan/confirm card emitted); a string = the handler DECLINED + (invalid proposal) and the loop should continue with that as the tool result.""" + tool = self.registry.get(op["tool_name"]) + if tool and tool.handler: + return tool.handler(self, op["args"]) + return None + + def run_generation_step(self, tool, op: dict) -> tuple[str, list[dict]]: + """Run generate_page as one STEP of the turn (editor and headless alike). The + generator persists the page server-side; point the working tree at the result + so the model can read back — and build on — what it just made (scripts, + surgical fixes, one verify pass).""" + if not self.page_id: + return ("FAILED: no page is open — call create_page or open_page first, then generate.", []) + entry = self.begin_activity(op["tool_name"], op["args"]) + self.ensure_revert_snapshot() # generation replaces the block tree + ops = tool.generator(self, op["args"]) + self.end_activity(entry) + if not ops: + return ("FAILED: generation produced nothing. Retry generate_page with a fuller brief.", []) + root = ops[0]["args"]["blocks"][0] + self.tree = WorkingTree(root) + return ( + "Page generated and saved. Now finish the build: add the client scripts the plan " + "calls for (set_page_script), fix obvious breakage with the block tools, verify " + "with preview_page at most once, then finish with a short summary." + f"{self.script_hook_gap_note(root)}\n" + f"{render_page_context(root)}", + ops, + ) + + def script_hook_gap_note(self, root: dict) -> str: + """The class-contract check: scripts written in parallel with generation + target class hooks the generated blocks must carry — a missing hook silently + kills the behaviour (seen live: scripts selecting .suraj-project-card on a + page whose blocks only carried .suraj-reveal). Compare the attached scripts' + querySelector targets and CSS class selectors against the blocks' classes + and tell the model NOW, while it can still patch with update_blocks.""" + if not self.page_id: + return "" + names = frappe.db.get_all( + "Builder Page Client Script", + filters={"parent": self.page_id, "parenttype": "Builder Page"}, + pluck="builder_script", + ) + if not names: + return "" + scripts = frappe.get_all( + "Builder Client Script", filters={"name": ["in", names]}, fields=["script_type", "script"] + ) + selected, runtime_added, css_used = set(), set(), set() + for s in scripts: + text = s.script or "" + if s.script_type == "CSS": + css_used.update(re.findall(r"\.([a-zA-Z][\w-]{2,})", text)) + else: + selected.update(re.findall(r"""querySelector(?:All)?\(\s*['"]\.([\w-]+)""", text)) + # Classes the JS creates/toggles at runtime are not expected on blocks. + runtime_added.update( + re.findall(r"""classList\.(?:add|remove|toggle)\(\s*['"]([\w-]+)""", text) + ) + runtime_added.update(re.findall(r"""\.className\s*=\s*['"]([\w\s-]+)['"]""", text)) + blob = json.dumps(root) + present = set(re.findall(r'"([^"]+)"', " ".join(re.findall(r'"classes":\s*\[([^\]]*)\]', blob)))) + missing_js = sorted(selected - present - runtime_added) + missing_css = sorted(css_used - present - runtime_added - selected)[:8] + if not missing_js and not missing_css: + return "" + parts = ["\nCLASS CONTRACT CHECK:"] + if missing_js: + parts.append( + f"your JS selects {', '.join('.' + c for c in missing_js)} but NO block carries " + "those classes — that behaviour will never fire. Add each class to the intended " + "blocks (update_blocks) or rewrite the script." + ) + if missing_css: + parts.append( + f"CSS rules target unused classes: {', '.join('.' + c for c in missing_css)} — " + "apply them to the intended blocks or they are dead styling." + ) + return " ".join(parts) + + +def run_agent_job(prompt: str, model: str, api_key: str, **kwargs): + # A page-less turn is the dashboard orchestrator: no canvas, so it uses the + # orchestrator registry (server tools + parallel fan-out) and its own system prompt. + # The registry is built HERE (in the worker) rather than pickled through enqueue. + if not kwargs.get("page_id") and not kwargs.get("registry"): + from builder.ai.agent.registry import build_orchestrator_registry + + kwargs["registry"] = build_orchestrator_registry() + # The editor's URL prefix is site-configurable — resolve it here so the + # links the agent writes actually work on this site. + builder_path = frappe.conf.builder_path or "builder" + kwargs.setdefault( + "system_prompt", Prompts.ORCHESTRATOR_SYSTEM.replace("{BUILDER_PATH}", builder_path) + ) + kwargs["headless"] = True + AgentRunner(prompt, model, api_key, **kwargs).run() diff --git a/builder/ai/agent/pending.py b/builder/ai/agent/pending.py new file mode 100644 index 000000000..b32e239b2 --- /dev/null +++ b/builder/ai/agent/pending.py @@ -0,0 +1,362 @@ +"""Confirm-gating for sensitive agent actions. + +A sensitive tool NEVER mutates directly. It calls `request_confirmation`, which +persists a pending-action message and emits a clarify event carrying the payload, +then ends the turn (the tool is `side="terminal"`). The frontend renders an +Apply/Skip card; on Apply it calls the `confirm_pending_settings` endpoint, which +loads the stored payload and runs `apply_pending_action`. So every privileged write +(global settings, home page, new doctype, sample data, publish) is user-triggered — +the model can only *propose*. +""" + +import frappe + +from builder.ai.session import AISession + +# Sensitive action kinds the confirm card understands. Kept explicit so an unknown +# kind can never be applied. +KINDS = { + "home_page", + "global_settings", + "create_doctype", + "seed_sample_data", + "publish_site", + "manage_pages", + "connect_form", +} + +GLOBAL_SETTING_FIELDS = {"script", "style", "head_html", "body_html"} + + +def request_confirmation(ctx, kind: str, summary: str, payload: dict) -> None: + """Persist + emit a pending sensitive action, then end the turn without mutating.""" + message_id = AISession.try_append_message( + ctx.session_id, + "assistant", + summary, + message_type="clarification", + task_type="agent", + metadata={"status": "pending_action", "kind": kind, "payload": payload}, + ) + frappe.db.commit() + ctx.emit( + "clarify", + question=summary, + options=["Apply", "Skip"], + pending_action={"kind": kind, "payload": payload}, + message_id=message_id, + ) + + +def apply_pending_action(kind: str, payload: dict) -> str: + """Run the real mutation for a confirmed action. Called ONLY from the confirm + endpoint (user-triggered). Returns a short human summary of what happened.""" + if kind not in KINDS: + frappe.throw(frappe._("Unknown pending action: {0}").format(kind)) + payload = payload or {} + return { + "home_page": apply_home_page, + "global_settings": apply_global_settings, + "create_doctype": apply_create_doctype, + "seed_sample_data": apply_seed_sample_data, + "publish_site": apply_publish_site, + "manage_pages": apply_manage_pages, + "connect_form": apply_connect_form, + }[kind](payload) + + +def apply_home_page(payload: dict) -> str: + route = (payload.get("route") or "").strip().lstrip("/") + frappe.db.set_value("Builder Settings", None, "home_page", route) + return frappe._("Home page set to /{0}").format(route) + + +def apply_global_settings(payload: dict) -> str: + settings = frappe.get_single("Builder Settings") + changed = [] + for field in GLOBAL_SETTING_FIELDS: + if field in payload and payload[field] is not None: + settings.set(field, payload[field]) + changed.append(field) + if changed: + settings.save(ignore_permissions=True) + return frappe._("Updated global settings: {0}").format(", ".join(changed) or "nothing") + + +def apply_create_doctype(payload: dict) -> str: + """Create a Custom DocType (custom=1, created at runtime — no code files / app + migration) with Guest read so public pages can query it.""" + name = (payload.get("name") or "").strip() + if not name: + frappe.throw(frappe._("Doctype name is required")) + if frappe.db.exists("DocType", name): + return frappe._("DocType {0} already exists").format(name) + + fields = [] + for f in payload.get("fields") or []: + fieldname = (f.get("fieldname") or "").strip() + if not fieldname: + continue + fields.append( + { + "fieldname": fieldname, + "label": f.get("label") or fieldname.replace("_", " ").title(), + "fieldtype": f.get("fieldtype") or "Data", + "options": f.get("options"), + "in_list_view": 1, + } + ) + if not fields: + frappe.throw(frappe._("At least one field is required")) + + frappe.get_doc( + { + "doctype": "DocType", + "name": name, + "module": "Builder", + "custom": 1, + "naming_rule": "Random", + "fields": fields, + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + # Public website pages read data as Guest. + {"role": "Guest", "read": 1}, + ], + } + ).insert(ignore_permissions=True) + return frappe._("Created DocType {0} with {1} field(s)").format(name, len(fields)) + + +def apply_seed_sample_data(payload: dict) -> str: + doctype = (payload.get("doctype") or "").strip() + rows = payload.get("rows") or [] + if not doctype or not frappe.db.exists("DocType", doctype): + frappe.throw(frappe._("DocType {0} does not exist").format(doctype)) + created = 0 + for row in rows: + if not isinstance(row, dict): + continue + frappe.get_doc({"doctype": doctype, **row}).insert(ignore_permissions=True) + created += 1 + return frappe._("Seeded {0} sample record(s) into {1}").format(created, doctype) + + +MANAGE_PAGE_ACTIONS = {"publish", "unpublish", "delete"} + + +def apply_manage_pages(payload: dict) -> str: + """Page lifecycle, user-confirmed: publish / unpublish / delete. Runs WITHOUT + ignore_permissions — the confirming user's own rights decide.""" + action = (payload.get("action") or "").strip() + if action not in MANAGE_PAGE_ACTIONS: + frappe.throw(frappe._("Unknown page action: {0}").format(action)) + titles = [] + for page_id in payload.get("page_ids") or []: + if not frappe.db.exists("Builder Page", page_id): + continue + doc = frappe.get_doc("Builder Page", page_id) + titles.append(doc.page_title or page_id) + if action == "delete": + frappe.delete_doc("Builder Page", page_id) + elif action == "unpublish": + doc.unpublish() + else: + doc.publish() + if not titles: + frappe.throw(frappe._("No matching pages")) + verb = {"publish": "Published", "unpublish": "Unpublished", "delete": "Deleted"}[action] + return frappe._("{0} {1} page(s): {2}").format(verb, len(titles), ", ".join(titles)) + + +def apply_publish_site(payload: dict) -> str: + folder = (payload.get("folder") or "").strip() + if not folder: + frappe.throw(frappe._("Folder is required to publish a site")) + pages = frappe.get_all("Builder Page", filters={"project_folder": folder}, pluck="name") + published = 0 + for name in pages: + doc = frappe.get_doc("Builder Page", name) + doc.publish() + published += 1 + frappe.db.set_value( + "Builder Project Folder", folder, "generation_status", "Published", update_modified=False + ) + return frappe._("Published {0} page(s)").format(published) + + +def apply_connect_form(payload: dict) -> str: + """Wire a page form to real data: a private submission DocType + a guest-safe + Web Form (the trusted, rate-limited, field-whitelisted boundary) + a client + script that POSTs the form's fields to the Web Form's accept endpoint. Reused + if the doctype/web form already exist. Returns the Desk link to the entries.""" + from builder.ai.agent.tools.forms import desk_slug + + doctype = (payload.get("doctype_name") or "").strip() + fields = payload.get("fields") or [] + page_id = (payload.get("page_id") or "").strip() + selector = (payload.get("form_selector") or "").strip() + if not doctype or not fields or not selector: + frappe.throw(frappe._("connect_form needs a doctype, fields, and a form selector")) + + # 1. private submission DocType — System Manager only, NO guest permission + # (the Web Form is the trusted boundary; submissions must not be readable by + # other guests). + # Only DocType field keys (drop input_name, which is the browser-side name attr). + doctype_fields = [ + {k: v for k, v in f.items() if k in ("fieldname", "label", "fieldtype", "options")} + | {"in_list_view": 1} + for f in fields + ] + if not frappe.db.exists("DocType", doctype): + frappe.get_doc( + { + "doctype": "DocType", + "name": doctype, + "module": "Builder", + "custom": 1, + "naming_rule": "Random", + "fields": doctype_fields, + "permissions": [{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}], + } + ).insert(ignore_permissions=True) + + # 2. Web Form bound to it — guest-allowed (login_required off), published. + wf_name = desk_slug(doctype) + if not frappe.db.exists("Web Form", wf_name): + frappe.get_doc( + { + "doctype": "Web Form", + "name": wf_name, + "title": doctype, + "route": f"forms/{wf_name}", + "doc_type": doctype, + "module": "Builder", + "published": 1, + "login_required": 0, + "allow_multiple": 1, + "web_form_fields": [ + {"fieldname": f["fieldname"], "label": f["label"], "fieldtype": f["fieldtype"]} + for f in fields + ], + } + ).insert(ignore_permissions=True) + + # 3. client script on the page: capture the form's fields and POST them to the + # stock Web Form accept endpoint (guest-safe, rate-limited, ignore_permissions). + # Fieldnames in FORM ORDER — the script maps inputs positionally (preferring a + # matching name attr) so it works even if the form has no name attrs/class. + if page_id and frappe.db.exists("Builder Page", page_id): + ordered_fieldnames = [f["fieldname"] for f in fields] + attach_form_script(page_id, doctype, wf_name, selector, ordered_fieldnames) + + frappe.db.commit() + slug = desk_slug(doctype) + return frappe._( + "Form connected — submissions save to '{0}'. View them in Desk: [/app/{1}](/app/{1})" + ).format(doctype, slug) + + +def attach_form_script(page_id: str, doctype: str, web_form: str, selector: str, fieldnames: list) -> None: + """Create + attach a Builder Client Script that submits the form's inputs to + the Web Form accept endpoint. Idempotent per (page, web_form).""" + script_name = f"Save {doctype} submissions" + existing = frappe.db.get_value("Builder Client Script", {"name": script_name}, "name") + code = build_form_script(selector, web_form, fieldnames) + if existing: + # save(), NOT db.set_value — the doc's on_update regenerates the served + # public .js file; a bare set_value leaves the page serving stale JS. + doc = frappe.get_doc("Builder Client Script", existing) + doc.script = code + doc.save(ignore_permissions=True) + script_id = existing + else: + script_id = ( + frappe.get_doc( + { + "doctype": "Builder Client Script", + "name": script_name, + "script_type": "JavaScript", + "script": code, + } + ) + .insert(ignore_permissions=True) + .name + ) + page = frappe.get_doc("Builder Page", page_id) + if not any(r.builder_script == script_id for r in page.get("client_scripts") or []): + page.append("client_scripts", {"builder_script": script_id}) + page.save(ignore_permissions=True) + + +def build_form_script(selector: str, web_form: str, fieldnames: list) -> str: + """The submit-wiring JS. `fieldnames` are the DocType fieldnames in FORM ORDER. + It finds the form (the given selector, else auto-detects the container that best + matches — has a submit control and roughly `fieldnames` many inputs), maps each + input to a field POSITIONALLY (preferring a matching name attr), and POSTs to + the stock accept endpoint. Robust to a form with no name attrs or class.""" + import json + + sel = json.dumps(selector or "") + wf = json.dumps(web_form) + fields = json.dumps(fieldnames) + return f"""// Auto-wired by Bob: saves this form to the '{web_form}' Web Form. +(function () {{ + // The script loads in , before the form is parsed — wait for the DOM. + if (document.readyState === 'loading') {{ document.addEventListener('DOMContentLoaded', init); }} + else {{ init(); }} + function init() {{ + var SEL = {sel}, WF = {wf}, FIELDS = {fields}; + var Q = 'input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea'; + function findForm() {{ + if (SEL) {{ var f = document.querySelector(SEL); if (f && f.querySelector(Q)) return f; }} + // Fallback: the tightest container that has a submit control and about the + // expected number of inputs — the form even without a class hook. + var best = null, bestScore = -1; + document.querySelectorAll('form, section, div').forEach(function (c) {{ + var ins = c.querySelectorAll(Q); + if (!ins.length || ins.length > 15) return; + if (!c.querySelector('button, [type="submit"], [type="button"]')) return; + var score = 100 - Math.abs(ins.length - FIELDS.length) * 20 - Math.min(c.querySelectorAll('*').length, 400) / 20; + if (score > bestScore) {{ bestScore = score; best = c; }} + }}); + return best; + }} + var form = findForm(); + if (!form) return; + var inputs = Array.prototype.slice.call(form.querySelectorAll(Q)); + var btn = form.querySelector('button, [type="submit"], input[type="submit"], [type="button"]'); + var busy = false; + function submit(e) {{ + if (e) e.preventDefault(); + if (busy) return false; + var data = {{}}; + inputs.forEach(function (el, i) {{ + var fn = (el.name && FIELDS.indexOf(el.name) !== -1) ? el.name : FIELDS[i]; + if (fn && (el.value || '').trim()) data[fn] = el.value; + }}); + if (!Object.keys(data).length) return false; + busy = true; + var label = btn && btn.textContent; + if (btn) {{ btn.textContent = 'Sending…'; btn.disabled = true; }} + fetch('/api/method/frappe.website.doctype.web_form.web_form.accept', {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ web_form: WF, data: data }}) + }}).then(function (r) {{ if (!r.ok) throw r; return r.json(); }}) + .then(function () {{ + if (btn) btn.textContent = 'Thank you!'; + inputs.forEach(function (el) {{ el.value = ''; }}); + }}) + .catch(function () {{ + busy = false; + if (btn) {{ btn.textContent = label || 'Try again'; btn.disabled = false; }} + }}); + return false; + }} + // Attach to BOTH: the button click (fires whether or not it's type=submit) and, + // for a real
, the submit event (Enter key). The busy flag dedupes. + if (btn) btn.addEventListener('click', submit); + if (form.tagName === 'FORM') form.addEventListener('submit', submit); + }} +}})(); +""" diff --git a/builder/ai/agent/registry.py b/builder/ai/agent/registry.py new file mode 100644 index 000000000..bf7ff3c1f --- /dev/null +++ b/builder/ai/agent/registry.py @@ -0,0 +1,229 @@ +"""Tool registry for the Builder AI agent. + +Every capability the agent has is a `Tool`. A tool declares its OpenAI-style +function schema and a *side* that tells the loop how to handle a call to it: + + - "client": a page edit (block ops, scripts). The loop applies it to the + authoritative server-side WorkingTree first, then mirrors the + accepted ops to the editor canvas (a live view). + - "server": the loop runs `handler(ctx, args)` immediately and feeds the + returned string back to the model as a tool result, then loops. + - "terminal": the call ends the turn and hands control back to the user + (e.g. ask a clarifying question, propose a plan). The loop calls + `handler(ctx, args)` to emit the appropriate event and stops. + +A tool may additionally produce a large *streamed artifact* (e.g. a full page +of YAML). Such a tool sets `artifact` + `generator`: when the conversational +model calls it, the loop hands execution to the generator, which produces the +artifact on the heavy model and streams it to the client as content (reliable), +then returns the canonical client op(s) to apply. The agent calling the tool is +the signal to generate — no out-of-band state decides it. + +Adding a capability = registering one `Tool`. The loop never changes. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +ToolSide = Literal["client", "server", "terminal"] + + +@dataclass +class Tool: + name: str + side: ToolSide + description: str + parameters: dict + # Required for "server" and "terminal" tools; ignored for "client" tools. + # Signature: handler(ctx, args: dict) -> str | None + handler: Callable | None = None + # When set, this tool produces a streamed artifact of the given kind (e.g. + # "page_yaml"). The loop runs `generator(ctx, args) -> list[dict]` instead of + # emitting a plain client op; the generator streams the artifact as content + # and returns the canonical client op(s) to apply. None = ordinary tool. + artifact: str | None = None + generator: Callable | None = None + + def schema(self) -> dict: + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + +class ToolRegistry: + def __init__(self): + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> Tool: + self._tools[tool.name] = tool + return tool + + def extend(self, tools: list[Tool]) -> None: + for tool in tools: + self.register(tool) + + def get(self, name: str) -> Tool | None: + return self._tools.get(name) + + def side(self, name: str) -> ToolSide: + tool = self._tools.get(name) + return tool.side if tool else "client" + + def schemas(self) -> list[dict]: + return [tool.schema() for tool in self._tools.values()] + + def names(self) -> list[str]: + return list(self._tools) + + +def pick(tools: list[Tool], names: set[str]) -> list[Tool]: + """Select tools by name — used to compose the headless registries from the same + module TOOLS lists as the interactive one, without duplicating tool definitions.""" + return [t for t in tools if t.name in names] + + +def build_default_registry() -> ToolRegistry: + """Assemble the registry from the tool modules. Imported lazily to avoid + import cycles (tool handlers reference the agent context type).""" + from builder.ai.agent.tools import ( + blocks, + codebase, + components, + conversation, + data, + forms, + generate, + images, + memory, + orchestrate, + pages, + preview, + query, + sandbox, + scripts, + settings, + ) + + registry = ToolRegistry() + registry.extend(generate.TOOLS) + registry.extend(blocks.TOOLS) + registry.extend(query.TOOLS) + registry.extend(scripts.TOOLS) + registry.extend(conversation.TOOLS) + registry.extend(memory.TOOLS) + registry.extend(data.TOOLS) + registry.extend(forms.TOOLS) + registry.extend(settings.TOOLS) + registry.extend(images.TOOLS) + # Whole-site capabilities in the editor: focus/create/manage other pages, + # screenshot self-review, and parallel fan-out for multi-page builds. + registry.extend(pages.TOOLS) + registry.extend(preview.TOOLS) + registry.extend(orchestrate.TOOLS) + registry.extend(components.TOOLS) + # Primitives from the codebase-context experiment: run_python covers bulk or + # unusual page mutations the block tools don't express well, and source access + # lets the model check Builder mechanics instead of guessing. + registry.extend(codebase.TOOLS) + registry.extend(sandbox.TOOLS) + return registry + + +def headless_page_tools() -> list[Tool]: + """The page capabilities every HEADLESS agent gets: focus a page (open/create), + read any page, generate a full page, edit it surgically with the block tools + (applied server-side by the mutating WorkingTree), query its structure, find + real photos for it, and screenshot it for a self-review pass.""" + from builder.ai.agent.tools import blocks, generate, images, pages, preview, query + + return [*pages.TOOLS, *generate.TOOLS, *blocks.TOOLS, *query.TOOLS, *preview.TOOLS, *images.TOOLS] + + +def build_orchestrator_registry() -> ToolRegistry: + """The page-less dashboard chat — the full builder. It reads/creates/edits/ + generates single pages inline (headless page tools) and reserves + `spawn_parallel_agents` for genuinely parallel multi-page work, after laying + down shared assets (theme variables, header/footer components). Site-wide + + data-model changes stay confirm-gated.""" + from builder.ai.agent.tools import ( + components, + conversation, + data, + forms, + memory, + orchestrate, + scripts, + settings, + ) + + registry = ToolRegistry() + registry.extend(conversation.TOOLS) # present_ui + registry.extend(memory.TOOLS) + registry.extend(components.TOOLS) + registry.extend(headless_page_tools()) + registry.extend(forms.TOOLS) # connect_form (confirm-gated) + registry.extend(scripts.TOOLS) # set/update apply via their headless handlers + registry.extend( + pick( + data.TOOLS, + { + "list_doctypes", + "get_doctype_schema", + "query_records", + "get_document", + "write_page_data_script", + "create_doctype", + "seed_sample_data", + }, + ) + ) + registry.extend( + pick( + settings.TOOLS, + { + "set_design_token", + "set_page_settings", + "set_home_page", + "edit_global_settings", + "publish_site", + }, + ) + ) + registry.extend(orchestrate.TOOLS) # spawn_parallel_agents, create_component + return registry + + +def build_subagent_registry() -> ToolRegistry: + """One headless page builder in a fan-out. Same page capabilities as the + orchestrator minus focus-switching (open_page/create_page — a child stays on its + assigned page; read_page covers references), with NO `spawn_parallel_agents` + (recursion guard) and NO confirm-gated terminal tools (no user to confirm in a + worker).""" + from builder.ai.agent.tools import data, scripts, settings + + registry = ToolRegistry() + # No focus-switching, and no confirm-gated lifecycle (nobody to confirm in a worker). + registry.extend( + [t for t in headless_page_tools() if t.name not in {"open_page", "create_page", "manage_pages"}] + ) + registry.extend( + pick( + data.TOOLS, + { + "list_doctypes", + "get_doctype_schema", + "query_records", + "get_document", + "write_page_data_script", + }, + ) + ) + registry.extend(pick(settings.TOOLS, {"set_page_settings", "set_design_token"})) + registry.extend(scripts.TOOLS) # set/update apply via their headless handlers + return registry diff --git a/builder/ai/agent/selectors.py b/builder/ai/agent/selectors.py new file mode 100644 index 000000000..bcc2dae5e --- /dev/null +++ b/builder/ai/agent/selectors.py @@ -0,0 +1,84 @@ +"""Tree helpers for the agent's selector tools. + +The server already holds the full page tree (the frontend ships it as +`page_context`), so block selection and inspection are answered here without a +frontend round-trip. These walk the native block dict (element / blockId / +children / …) — the same shape `BlockCodec` operates on. +""" + +from collections.abc import Iterator + + +def walk_blocks(root: dict, depth: int = 0) -> Iterator[tuple[dict, int]]: + """Yield (block, depth) for the root and every descendant, depth-first.""" + if not isinstance(root, dict): + return + yield root, depth + for child in root.get("children") or []: + if isinstance(child, dict): + yield from walk_blocks(child, depth + 1) + + +def find_block(root: dict, block_id: str) -> dict | None: + """Return the block with this blockId, or None.""" + for block, _depth in walk_blocks(root): + if block.get("blockId") == block_id: + return block + return None + + +def block_text(block: dict) -> str: + """The block's own text content (innerHTML), stripped. Empty for containers.""" + return (block.get("innerHTML") or "").strip() + + +def render_skeleton(root: dict, max_text: int = 60) -> str: + """A compact one-line-per-block outline: indent shows nesting, then the block's + ref, element, optional name, and a short text preview. Styles and attributes are + omitted — the model pulls those with read_block when it actually needs them. Text + is previewed only; query_blocks returns it in full for bulk edits.""" + lines: list[str] = [] + for block, depth in walk_blocks(root): + ref = block.get("blockId") or "?" + el = block.get("element") or "div" + parts = [f"{' ' * depth}{ref} {el}"] + if name := block.get("blockName"): + parts.append(f"({name})") + if text := block_text(block): + preview = text if len(text) <= max_text else text[: max_text - 1] + "…" + parts.append(f'"{preview}"') + lines.append(" ".join(parts)) + return "\n".join(lines) + + +def is_text_block(block: dict) -> bool: + """A leaf block that carries user-visible copy. Defined by SHAPE, not a tag + whitelist: non-empty innerHTML and no block children — so it catches text in + non-semantic containers too (a div/td/dd with direct text), which is common on + imported/replicated pages and is exactly what a translate-everything must reach. + Excludes raw SVG/markup blobs (decorative illustrations live in a div's innerHTML) + — that is not copy to translate.""" + text = block_text(block) + if not text or block.get("children"): + return False + return not text.lstrip().lower().startswith(" bool: + """Does this block satisfy every supplied filter? Filters AND together.""" + if element and (block.get("element") or "").lower() != element.lower(): + return False + if text_only and not is_text_block(block): + return False + if contains and contains.lower() not in block_text(block).lower(): + return False + if class_name and class_name not in (block.get("classes") or []): + return False + return True diff --git a/builder/ai/agent/tools/__init__.py b/builder/ai/agent/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/builder/ai/agent/tools/blocks.py b/builder/ai/agent/tools/blocks.py new file mode 100644 index 000000000..01b128d21 --- /dev/null +++ b/builder/ai/agent/tools/blocks.py @@ -0,0 +1,307 @@ +"""Block-editing tools. All are client-side: the loop batches the operations +and the frontend applies them to the canvas block tree.""" + +from builder.ai.agent.registry import Tool + +update_block = Tool( + name="update_block", + side="client", + description=( + "Merge style, attribute, or content changes into an existing block. " + "Use this to change colours, fonts, spacing, text, HTML attributes, " + "element type, or class names on ANY block at any nesting depth. " + "Make sure to set units on style values (e.g. padding: '10px' not just 10)." + ), + parameters={ + "type": "object", + "properties": { + "block_id": { + "type": "string", + "description": "The target block's 'ref' value (from the page YAML).", + }, + "base_styles": { + "type": "object", + "description": "CSS-in-JS camelCase style properties to merge into baseStyles (desktop). E.g. {backgroundColor: '#ff0000'}.", + }, + "mobile_styles": { + "type": "object", + "description": "CSS-in-JS style properties to merge into mobileStyles.", + }, + "tablet_styles": { + "type": "object", + "description": "CSS-in-JS style properties to merge into tabletStyles.", + }, + "attributes": { + "type": "object", + "description": "HTML attributes to merge (e.g. {href: '/about', target: '_blank'} for links, {src: '...', alt: '...'} for images, {id: 'hero-section'} for HTML id). attrs.id sets the HTML id; the block's ref (passed as block_id) is its editor handle — separate things.", + }, + "inner_text": { + "type": "string", + "description": "Replace the text content of the block (plain text).", + }, + "inner_html": { + "type": "string", + "description": "Replace the inner HTML of the block (use for rich content).", + }, + "element": { + "type": "string", + "description": "Change the HTML element tag (e.g. 'h1', 'button', 'a').", + }, + "classes": { + "type": "array", + "items": {"type": "string"}, + "description": "Replace the classes array on the block.", + }, + "bind": { + "type": "object", + "description": ( + "Data bindings: {property: data_key}, e.g. {innerHTML: 'title', src: 'image', " + "href: 'url'}. Keys are BARE names: inside a repeater's item template the key " + "is a field of each record ('image', NOT 'item.image'); elsewhere it's a " + "page-data key ('merch_items', NOT 'data.merch_items'). This is the ONLY way " + "to render dynamic data — NEVER write '{{ item.title }}' moustache text into " + "inner_text/attributes (it renders literally). A null value unbinds." + ), + }, + }, + "required": ["block_id"], + }, +) + +add_block = Tool( + name="add_block", + side="client", + description=( + "Insert a new block as a child of an existing block. " + "Use this to add sections, components, or elements anywhere in the page tree. " + "NOT for JavaScript or CSS: never add a diff --git a/frontend/src/components/AIChatController.ts b/frontend/src/components/AIChatController.ts new file mode 100644 index 000000000..c89ac41c3 --- /dev/null +++ b/frontend/src/components/AIChatController.ts @@ -0,0 +1,880 @@ +import type Block from "@/block"; +import { BatchTracker } from "@/components/ai/batches"; +import { setCostCurrency } from "@/components/ai/format"; +import builderTokens from "@/data/builderToken"; +import { type AIChatHandlers, attachAIChatListeners, detachAIChatListeners } from "@/components/ai/realtime"; +import { ToolDispatcher } from "@/components/ai/toolDispatch"; +import type { AIProvider, ChatMessage } from "@/components/ai/types"; +import { buildLocalMessage } from "@/components/ai/yaml"; +import useBuilderStore from "@/stores/builderStore"; +import useCanvasStore from "@/stores/canvasStore"; +import usePageStore from "@/stores/pageStore"; +import { confirm } from "@/utils/helpers"; +import { useLocalStorage } from "@vueuse/core"; +import { createResource, toast } from "frappe-ui"; +import { computed, nextTick, ref, watch } from "vue"; +import router from "@/router"; +import { useRoute } from "vue-router"; + +// Re-exported for components that still import these from here. +export type { AffectedBlock, AffectedScript, AIModel, AIProvider, ChatMessage } from "@/components/ai/types"; + +/** + * Orchestrates the Builder AI chat: holds UI state, sends each user turn to the + * single `builder.ai.api.run` endpoint, and reacts to the `ai_chat_*` realtime + * events. Block-tree mutation lives in ToolDispatcher; YAML parsing in ./ai/yaml. + */ +export class AIChatController { + private readonly builderStore = useBuilderStore(); + private readonly canvasStore = useCanvasStore(); + private readonly pageStore = usePageStore(); + private readonly route = useRoute(); + private readonly dispatcher: ToolDispatcher; + + readonly prompt = ref(""); + readonly progressMessage = ref(""); + readonly isSubmitting = ref(false); + readonly isCancelling = ref(false); // true between clicking stop and the backend's cancelled event + readonly messageContainer = ref(null); + + readonly imageData = ref(null); + readonly imagePreviewUrl = ref(null); + readonly imageFileName = ref(""); + readonly isDragging = ref(false); + + readonly sessionId = ref(""); + readonly messages = ref([]); + // This page's chat sessions (most recent first) — the panel's session switcher. + readonly sessions = ref>([]); + readonly availableModels = ref([]); + readonly selectedModel = useLocalStorage("ai-selected-model", ""); + + // spawn_parallel_agents fan-outs this session — the task-group card's state. + readonly batchTracker = new BatchTracker(); + readonly batches = this.batchTracker.batches; + readonly publishingBatch = ref(false); + + // Floating build indicator. originPage: a build driven from ANOTHER chat is + // writing to THIS page (watch-live) — link back to that chat. targetPage: this + // chat's agent is building a DIFFERENT page — link there to watch it live. + readonly foreignBuild = ref<{ originPage: string | null; targetPage: string | null } | null>(null); + private foreignBuildTimer: ReturnType | null = null; + + private noteForeignBuild(originPage?: string | null, targetPage?: string | null) { + this.foreignBuild.value = { originPage: originPage || null, targetPage: targetPage || null }; + if (this.foreignBuildTimer) clearTimeout(this.foreignBuildTimer); + // No completion event is guaranteed to reach us; fade the pill out once the + // build goes quiet. Generous window: a build's quiet gaps (scripts round, + // preview screenshot) run well past a few seconds, and losing the pill also + // loses the user's only way back to the driving chat. + this.foreignBuildTimer = setTimeout(() => (this.foreignBuild.value = null), 30000); + } + + /** While a build stream owns this page's canvas, the editor's autosave stands + * down — persisting the partial preview is how a mid-build refresh (or an + * off-target render) corrupts the draft. The server saves the real result. */ + private buildQuietTimer: ReturnType | null = null; + + private beginCanvasBuild() { + this.builderStore.aiBuildingCanvas = true; + if (this.buildQuietTimer) clearTimeout(this.buildQuietTimer); + // If the run dies without a complete event, release the canvas and resync + // the draft from the server so the user isn't left editing a dead preview. + this.buildQuietTimer = setTimeout(() => this.endCanvasBuild(true), 45000); + } + + private endCanvasBuild(resyncDraft = false) { + if (this.buildQuietTimer) { + clearTimeout(this.buildQuietTimer); + this.buildQuietTimer = null; + } + if (!this.builderStore.aiBuildingCanvas) return; + this.builderStore.aiBuildingCanvas = false; + this.builderStore.aiBuildStatus = ""; + // One-shot signal for the canvas to celebrate the finished build. + this.builderStore.aiBuildDoneTick++; + if (resyncDraft && this.pageId.value && this.pageId.value !== "new") { + this.pageStore.setPage(this.pageId.value, false); + } + } + + /** Accept one page_yaml chunk for the canvas. Drops chunks meant for another + * page (pill instead of paint), dedupes replayed chunks by stream offset, and + * refetches the server buffer when a gap shows we missed some. */ + private acceptPageYamlChunk(data: { + chunk?: string; + page_id?: string; + offset?: number; + origin_page?: string; + }): void { + if (data.page_id && data.page_id !== this.pageId.value) { + this.noteForeignBuild(null, data.page_id); + return; + } + if (typeof data.offset === "number") { + const have = this.pageStreamContent.value.length; + if (data.offset < have) return; + if (data.offset > have) { + this.syncActiveBuild(); + return; + } + } + this.beginCanvasBuild(); + this.previewUnconfirmed = true; + this.pageStreamContent.value += data.chunk!; + this.scheduleStreamRender(); + } + + /** A mid-build page load: replay the in-flight generation stream from the + * server's buffer so the canvas shows the live build, not the stale draft. */ + private syncTimer: ReturnType | null = null; + + private async syncActiveBuild() { + const pid = this.pageId.value; + if (!pid || pid === "new") return; + const build: any = await createResource({ url: "builder.ai.api.get_active_build" }) + .submit({ page_id: pid }) + .catch(() => null); + if (this.pageId.value !== pid) return; + // This page's own chat is building ANOTHER page: show the pill (with the + // target link) and keep it alive by re-checking while the run lasts. + if (build?.building_page && build.building_page !== pid) { + this.noteForeignBuild(null, build.building_page); + if (this.syncTimer) clearTimeout(this.syncTimer); + this.syncTimer = setTimeout(() => this.syncActiveBuild(), 15000); + } + if (!build?.yaml) return; + if (build.yaml.length <= this.pageStreamContent.value.length) return; + this.beginCanvasBuild(); + this.pageStreamContent.value = build.yaml; + if (build.origin_page && build.origin_page !== pid) { + this.noteForeignBuild(build.origin_page); + } + this.scheduleStreamRender(); + } + + // Set by the panel's style-preset picker; folded into the prompt on submit. + pendingStylePreset: string | null = null; + // Compact display line for a card-composed reply (set by selectOption). + private pendingDisplayText: string | null = null; + + private readonly pageStreamContent = ref(""); // accumulates kind="page_yaml" chunks + // True while the canvas shows a streamed preview that no authoritative + // tool_batch has replaced — if the turn ends in that state (failed generation, + // cancel), the preview is dead weight and the canvas must resync to the draft. + private previewUnconfirmed = false; + private readonly summaryContent = ref(""); // accumulates summary chunks + private readonly pendingAssistantId = ref(null); + private submittedForPageId: string | null = null; + // Streaming re-render is throttled: re-parsing + rebuilding the whole block tree + // on every chunk pegs the CPU. The final generate_page op re-applies the + // authoritative document, so this preview can render at a coarse cadence. + private static readonly STREAM_RENDER_MS = 200; + private streamRenderTimer: ReturnType | null = null; + private lastStreamRenderAt = 0; + + readonly pageId = computed(() => this.route.params.pageId as string); + readonly isUnsavedPage = computed(() => !this.pageId.value || this.pageId.value === "new"); + readonly currentProviderModels = computed( + () => this.availableModels.value.find((p) => p.provider === "openrouter")?.models || [], + ); + readonly selectedBlocks = computed( + () => (this.canvasStore.activeCanvas?.selectedBlocks || []) as Block[], + ); + readonly modelLabel = computed( + () => + this.currentProviderModels.value.find((m) => m.name === this.selectedModel.value)?.label || + "Select model", + ); + readonly modelOptions = computed(() => + this.currentProviderModels.value.map((m) => ({ + label: m.label, + onClick: () => (this.selectedModel.value = m.name), + })), + ); + readonly isVisionModel = computed( + () => this.currentProviderModels.value.find((m) => m.name === this.selectedModel.value)?.vision ?? false, + ); + readonly canSubmit = computed( + () => !!this.prompt.value.trim() && !this.isSubmitting.value && !!this.selectedModel.value, + ); + + constructor() { + this.dispatcher = new ToolDispatcher(this.pageStore, this.canvasStore, () => this.pageId.value); + + watch( + this.currentProviderModels, + (models) => { + const isValid = models.some((m) => m.name === this.selectedModel.value); + if (models.length && (!this.selectedModel.value || !isValid)) { + this.selectedModel.value = models[0].name; + } + }, + { immediate: true }, + ); + + watch(this.pageId, async (newPageId, oldPageId) => { + if (oldPageId) detachAIChatListeners(this.builderStore.realtime, oldPageId, this.handlers); + if (!newPageId) return; + attachAIChatListeners(this.builderStore.realtime, newPageId, this.handlers); + this.resetTransientState(); + // Sessions are page-scoped: never carry one across a page switch. + this.sessionId.value = ""; + this.sessions.value = []; + if (newPageId === "new") { + this.messages.value = []; + return; + } + await this.loadSession(); + // A build may be mid-stream on this page (opened from another chat's link, + // or a refresh mid-generation): replay the buffered stream as live preview. + this.syncActiveBuild(); + }); + } + + private get handlers(): AIChatHandlers { + return { + onProgress: this.onProgress, + onStream: this.onStream, + onToolBatch: this.onToolBatch, + onClarify: this.onClarify, + onComplete: this.onComplete, + onError: this.onError, + onToolActivity: this.onToolActivity, + onTaskGroup: this.onTaskGroup, + onRefetch: this.onRefetch, + }; + } + + resetTransientState() { + this.clearStreamRenderTimer(); + this.endCanvasBuild(); + // A pill carried across a page switch shows the WRONG flavor ("from another + // chat" on the chat that drives the build) — re-derived by syncActiveBuild. + this.foreignBuild.value = null; + if (this.foreignBuildTimer) clearTimeout(this.foreignBuildTimer); + this.progressMessage.value = ""; + this.pageStreamContent.value = ""; + this.summaryContent.value = ""; + this.pendingAssistantId.value = null; + this.dispatcher.reset(); + this.isSubmitting.value = false; + this.isCancelling.value = false; + } + + /** Throttle the streaming canvas preview: render at most every STREAM_RENDER_MS + * (leading + trailing) instead of re-parsing/rebuilding the whole tree per chunk. */ + private scheduleStreamRender() { + const elapsed = Date.now() - this.lastStreamRenderAt; + if (elapsed >= AIChatController.STREAM_RENDER_MS) { + this.flushStreamRender(); + } else if (this.streamRenderTimer === null) { + this.streamRenderTimer = setTimeout( + () => this.flushStreamRender(), + AIChatController.STREAM_RENDER_MS - elapsed, + ); + } + } + + private flushStreamRender() { + this.clearStreamRenderTimer(); + this.lastStreamRenderAt = Date.now(); + try { + this.dispatcher.applyPageYaml(this.pageStreamContent.value); + } catch {} + } + + private clearStreamRenderTimer() { + if (this.streamRenderTimer !== null) { + clearTimeout(this.streamRenderTimer); + this.streamRenderTimer = null; + } + } + + private replacePendingAssistant(content: string, metadata: Record = {}) { + if (!this.pendingAssistantId.value) return; + const index = this.messages.value.findIndex((m) => m.id === this.pendingAssistantId.value); + if (index === -1) return; + this.messages.value[index] = { + ...this.messages.value[index], + content, + metadata: { ...this.messages.value[index].metadata, ...metadata }, + }; + } + + private scrollToBottom() { + nextTick(() => { + if (this.messageContainer.value) { + this.messageContainer.value.scrollTop = this.messageContainer.value.scrollHeight; + } + }); + } + + /** Load a chat session: the given one, else the current one, else the page's + * most recently used (the server creates the first). A page can hold several + * parallel sessions — see switchSession/newSession. */ + async loadSession(sessionId?: string) { + if (!this.pageId.value || !this.builderStore.isAIEnabled || this.isUnsavedPage.value) return; + const result = await createResource({ + url: "builder.ai.api.get_ai_session", + makeParams: () => ({ + page_id: this.pageId.value, + model: this.selectedModel.value, + session_id: sessionId || this.sessionId.value || undefined, + }), + }).submit(); + const session = result as { session_id: string; messages: ChatMessage[] }; + this.sessionId.value = session.session_id; + this.messages.value = (session.messages || []).map( + (m) => ({ ...m, role: m.role === "user" ? "user" : "assistant" }) as ChatMessage, + ); + // Rehydrate task-group cards: fetch each batch's durable state (polling + // stops by itself once the batch settles). + for (const m of this.messages.value) { + const batchId = (m.metadata as any)?.batchId; + if (batchId) this.batchTracker.track(batchId); + } + this.loadSessions(); + } + + /** Refresh the session-switcher list (fire-and-forget; the panel renders it). */ + loadSessions = async () => { + if (!this.pageId.value || this.isUnsavedPage.value) return; + const rows = await createResource({ url: "builder.ai.api.list_page_ai_sessions" }) + .submit({ page_id: this.pageId.value }) + .catch(() => null); + if (rows) this.sessions.value = rows as Array<{ name: string; title: string | null }>; + }; + + switchSession = async (sessionId: string) => { + if (!sessionId || sessionId === this.sessionId.value) return; + this.resetTransientState(); + await this.loadSession(sessionId); + this.scrollToBottom(); + }; + + newSession = async () => { + if (!this.pageId.value || this.isUnsavedPage.value) return; + const result = await createResource({ url: "builder.ai.api.new_ai_session" }).submit({ + page_id: this.pageId.value, + model: this.selectedModel.value, + }); + this.resetTransientState(); + this.sessionId.value = (result as { session_id: string }).session_id; + this.messages.value = []; + this.loadSessions(); + }; + + deleteSession = async () => { + if (!this.sessionId.value) return; + if (!(await confirm("Delete this chat? Its messages are removed; the page itself is untouched."))) + return; + await createResource({ url: "builder.ai.api.delete_ai_session" }) + .submit({ session_id: this.sessionId.value }) + .catch(() => null); + this.resetTransientState(); + this.sessionId.value = ""; + await this.loadSession(); // falls back to the next most recent (or a fresh one) + }; + + clearImage = () => { + this.imageData.value = null; + this.imagePreviewUrl.value = null; + this.imageFileName.value = ""; + this.isDragging.value = false; + }; + + attachImageFile = (file: File) => { + if (!file.type.startsWith("image/")) return; + if (file.size > 5 * 1024 * 1024) return; + this.imageFileName.value = file.name || "pasted-image.png"; + const reader = new FileReader(); + reader.onload = (e) => { + this.imageData.value = e.target?.result as string; + this.imagePreviewUrl.value = this.imageData.value; + }; + reader.readAsDataURL(file); + }; + + // --- realtime handlers ------------------------------------------------ + + /** All events on this page's channel carry the session that produced them. + * With parallel sessions, chat-UI events from a session the user isn't + * viewing must not touch this view (canvas ops in onToolBatch still apply — + * the canvas is page-level, not session-level). */ + private isForeignSession(data: { session_id?: string }): boolean { + return !!(data.session_id && this.sessionId.value && data.session_id !== this.sessionId.value); + } + + onProgress = (data: { message?: string; session_id?: string }) => { + if (this.isForeignSession(data)) return; + this.isSubmitting.value = true; + this.progressMessage.value = data.message || this.progressMessage.value; + // Narrate the canvas build overlay with Bob's own words for this round. + if (data.message) this.builderStore.aiBuildStatus = data.message; + this.replacePendingAssistant(this.progressMessage.value || "Working...", { status: "running" }); + this.scrollToBottom(); + }; + + /** Live per-tool status: each tool call announces itself ("Set --color-primary", + * "Read block: Hero") as it starts, so a long tool-calling round shows real + * progress instead of a frozen "Thinking…". Only the "running" edge is shown; the + * model's streamed answer, once it begins, takes over the bubble. */ + onToolActivity = (data: { tool?: string; summary?: string; status?: string; session_id?: string }) => { + if (this.isForeignSession(data)) return; + if (!data.summary || (data.status && data.status !== "running")) return; + this.isSubmitting.value = true; + this.progressMessage.value = data.summary; + this.builderStore.aiBuildStatus = data.summary; + // Don't clobber the model's answer text once it has started streaming. + if (!this.summaryContent.value) { + this.replacePendingAssistant(data.summary, { status: "running" }); + } + this.scrollToBottom(); + }; + + onStream = (data: { + chunk?: string; + kind?: string; + session_id?: string; + origin_page?: string; + page_id?: string; + offset?: number; + }) => { + if (!data.chunk) return; + if (this.isForeignSession(data)) { + // A build driven from ANOTHER chat is streaming onto this page (watch-live). + // The canvas is page-level: apply the preview, and surface the floating + // "building live" indicator — but keep chat-text chunks out of this session. + if (data.kind === "page_yaml") { + if (!data.page_id || data.page_id === this.pageId.value) { + this.noteForeignBuild(data.origin_page); + } + this.acceptPageYamlChunk(data); + } + return; + } + this.isSubmitting.value = true; + if (data.kind === "page_yaml") { + this.acceptPageYamlChunk(data); + } else { + this.summaryContent.value += data.chunk; + this.replacePendingAssistant(this.summaryContent.value, { status: "running" }); + this.scrollToBottom(); + } + }; + + /** The agent fanned out parallel page builds (spawn_parallel_agents ends the + * turn). Track the batch so the panel's task-group card shows live progress; + * the reload on complete picks up the persisted message carrying batchId. */ + onTaskGroup = (data: { + batch_id?: string; + total?: number; + tasks?: Array>; + session_id?: string; + }) => { + if (!data.batch_id || this.isForeignSession(data)) return; + this.batchTracker.track(data.batch_id, { + total: data.total || data.tasks?.length || 0, + tasks: (data.tasks || []).map((t: any) => ({ ...t })), + }); + }; + + /** A server tool changed state the canvas only loads at editor start. Refetch + * exactly what changed so mid-turn results render without a manual refresh: + * theme variables (var(--id) styles), the evaluated page data (repeater + * previews), or the page doc (route/meta). NOT session-scoped — this state is + * page/site-level, so any chat's turn should refresh it. */ + onRefetch = async (data: { resources?: string[] }) => { + const resources = data.resources || []; + if (resources.includes("variables")) { + builderTokens.reload(); + } + if (resources.includes("page_data") || resources.includes("page")) { + const page = await this.pageStore.fetchActivePage(this.pageId.value).catch(() => null); + if (page) { + this.pageStore.activePage = page; + if (resources.includes("page_data")) await this.pageStore.setPageData(page); + } + } + }; + + cancelBatch = (batchId: string) => this.batchTracker.cancel(batchId); + + publishBatch = async (batchId: string) => { + this.publishingBatch.value = true; + try { + const res: any = await this.batchTracker.publish(batchId); + toast.success(res?.message || "Published"); + } catch (e: any) { + toast.error(e?.messages?.[0] || "Could not publish"); + } finally { + this.publishingBatch.value = false; + } + }; + + onToolBatch = (data: { + page_id?: string; + session_id?: string; + origin_page?: string; + operations?: Array<{ tool_name: string; args: Record }>; + }) => { + // Cancel any pending throttled stream render so it can't fire AFTER and clobber + // the authoritative apply below with stale partial YAML. + this.clearStreamRenderTimer(); + if (!data.operations?.length) return; + if (this.isForeignSession(data)) this.noteForeignBuild(data.origin_page); + // The agent may focus another page mid-turn (open_page/create_page): its ops + // are applied server-side; the canvas only mirrors ops for the page it shows. + if (data.page_id && data.page_id !== this.pageId.value) { + if (!this.isForeignSession(data)) this.noteForeignBuild(null, data.page_id); + return; + } + this.previewUnconfirmed = false; + for (const op of data.operations) { + this.dispatcher.trackAffectedItem(op.tool_name, op.args); // track before apply (remove_block) + try { + this.dispatcher.applyToolOperation(op.tool_name, op.args); + } catch (e) { + console.warn(`[AI agent] tool "${op.tool_name}" failed:`, e); + } + } + // Don't overwrite the bubble with a static "Applying N changes…" — the loop emits + // a per-round progress note (the model's words, or a "Updated N blocks" summary) + // right after each batch, which is what the user actually sees update. + this.scrollToBottom(); + }; + + onComplete = async (data: { message?: string; session_id?: string }) => { + if (this.isForeignSession(data)) { + // The other chat's build on this page finished; the authoritative + // tool_batch already replaced the streamed preview. + this.foreignBuild.value = null; + this.endCanvasBuild(); + return; + } + this.clearStreamRenderTimer(); + this.endCanvasBuild(this.previewUnconfirmed); + this.previewUnconfirmed = false; + if (this.submittedForPageId && this.submittedForPageId !== this.pageId.value) { + this.submittedForPageId = null; + return; + } + this.submittedForPageId = null; + this.isSubmitting.value = false; + this.isCancelling.value = false; + this.progressMessage.value = data.message || "Done"; + + let undoScripts: string[] = []; + if (this.dispatcher.pendingScriptOps.value.length) { + const names = await Promise.all(this.dispatcher.pendingScriptOps.value); + undoScripts = names.filter((n): n is string => !!n); + this.dispatcher.pendingScriptOps.value = []; + } + for (const name of undoScripts) { + if (!this.dispatcher.pendingAffectedScripts.value.find((s) => s.script_name === name)) { + this.dispatcher.pendingAffectedScripts.value.push({ script_name: name, changedProps: ["created"] }); + } + } + + const meta: Record = { status: "complete" }; + if (undoScripts.length) meta.undoScripts = undoScripts; + if (this.dispatcher.pendingAffectedBlocks.value.length) + meta.affectedBlocks = [...this.dispatcher.pendingAffectedBlocks.value]; + if (this.dispatcher.pendingAffectedScripts.value.length) + meta.affectedScripts = [...this.dispatcher.pendingAffectedScripts.value]; + this.replacePendingAssistant(this.progressMessage.value, meta); + this.pageStreamContent.value = ""; + this.summaryContent.value = ""; + this.dispatcher.reset(); + + const localMeta = { ...meta }; + if ( + this.sessionId.value && + (localMeta.affectedBlocks?.length || localMeta.affectedScripts?.length || localMeta.undoScripts?.length) + ) { + createResource({ url: "builder.ai.api.update_session_message_metadata" }) + .submit({ session_id: this.sessionId.value, metadata: localMeta }) + .catch(() => null); + } + + await this.loadSession(); + + // Re-apply client-only metadata in case the server hasn't flushed it yet. + if ( + localMeta.affectedBlocks?.length || + localMeta.affectedScripts?.length || + localMeta.undoScripts?.length + ) { + let idx = this.messages.value.length - 1; + while (idx >= 0 && this.messages.value[idx]?.role !== "assistant") idx--; + if (idx >= 0) { + this.messages.value[idx] = { + ...this.messages.value[idx], + metadata: { ...this.messages.value[idx].metadata, ...localMeta }, + }; + } + } + + this.scrollToBottom(); + window.setTimeout(() => { + this.progressMessage.value = ""; + this.pendingAssistantId.value = null; + }, 1200); + }; + + onError = async (data: { message?: string; session_id?: string }) => { + if (this.isForeignSession(data)) return; + this.clearStreamRenderTimer(); + this.endCanvasBuild(this.previewUnconfirmed); + this.previewUnconfirmed = false; + this.isSubmitting.value = false; + this.isCancelling.value = false; + this.progressMessage.value = ""; + this.replacePendingAssistant(data.message || "Request failed", { status: "error" }); + this.pageStreamContent.value = ""; + this.summaryContent.value = ""; + await this.loadSession(); + this.pendingAssistantId.value = null; + }; + + /** The agent composed a UI card (present_ui) — one generic renderer (AIUISpec) + * draws it. Confirm-gated actions arrive as pending_action instead and keep + * their dedicated Apply/Skip card. */ + onClarify = async (data: { + question?: string; + ui?: Array>; + pending_action?: { kind: string; payload: Record }; + session_id?: string; + }) => { + if (this.isForeignSession(data)) return; + this.clearStreamRenderTimer(); + this.endCanvasBuild(this.previewUnconfirmed); + this.previewUnconfirmed = false; + this.isSubmitting.value = false; + this.isCancelling.value = false; + this.progressMessage.value = ""; + this.pageStreamContent.value = ""; + this.summaryContent.value = ""; + + if (data.pending_action) { + this.replacePendingAssistant(data.question || "Confirm this change?", { + status: "pending_action", + kind: data.pending_action.kind, + payload: data.pending_action.payload, + }); + } else { + this.replacePendingAssistant(data.question || "…", { + status: "ui", + text: data.question || "", + ui: data.ui || [], + }); + } + this.pendingAssistantId.value = null; + // Backend persists+commits clarify messages before emitting, so this is race-free. + await this.loadSession(); + this.scrollToBottom(); + }; + + // --- user actions ----------------------------------------------------- + + /** Submit a reply composed by an agent UI card (option tap, action button, + * collected form values) as the user's next ordinary message. `display` is + * the compact line the chat shows instead of the full relay — the model + * still receives the full reply. */ + selectOption = (option: string, display?: string) => { + this.prompt.value = option; + this.pendingDisplayText = display?.trim() || null; + this.submitPrompt(); + }; + + /** Apply or skip a sensitive action the agent proposed (create doctype, seed data, + * global settings, publish). The privileged write happens server-side in the endpoint; + * we reload the session so the message's status flips out of "pending_action". */ + confirmPendingAction = async (message: ChatMessage, decision: "apply" | "skip") => { + try { + const res = await createResource({ + url: "builder.ai.api.confirm_pending_settings", + method: "POST", + }).submit({ message_id: message.id, decision }); + await this.loadSession(); + if (decision === "apply") toast.success(res?.message || "Applied"); + } catch (e: any) { + toast.error(e?.messages?.[0] || "Could not apply the change"); + } + }; + + + /** Ask the backend to abort the in-flight turn at its next stream chunk. + * Anthropic/OpenRouter stop billing once the stream is closed. The backend's + * cancelled `complete` event lands only after the next chunk + a round trip, + * so we show "Cancelling" locally right away for instant feedback; that event + * (or onError/onClarify) clears isCancelling when the turn actually ends. */ + cancel = async () => { + if (!this.sessionId.value || !this.isSubmitting.value || this.isCancelling.value) return; + this.isCancelling.value = true; + this.progressMessage.value = "Cancelling..."; + this.replacePendingAssistant("Cancelling...", { status: "running" }); + const resetStuckCancel = async () => { + if (!this.isCancelling.value) return; + this.endCanvasBuild(!!this.pageStreamContent.value); + this.resetTransientState(); + await this.loadSession(); + toast.info("That run is no longer active."); + }; + try { + const res: any = await createResource({ url: "builder.ai.api.cancel" }).submit({ + session_id: this.sessionId.value, + }); + // No live turn holds the session lock (it crashed or timed out): nothing + // will ever acknowledge the flag, so resolve the UI now. + if (res?.status === "not_running") await resetStuckCancel(); + } catch { + // Ignore — the user will see the event when it arrives. + } + // Watchdog: a wedged run (e.g. a stalled provider connection) can't reach its + // next cancellation check. Don't leave "Cancelling…" up forever. + setTimeout(resetStuckCancel, 20000); + }; + + submitPrompt = async () => { + if (!this.canSubmit.value || !this.pageId.value || this.isUnsavedPage.value) return; + + let userText = this.prompt.value.trim(); + this.prompt.value = ""; + if (this.pendingStylePreset) { + userText += `\n\n(Preferred visual style: ${this.pendingStylePreset})`; + this.pendingStylePreset = null; + } + this.submittedForPageId = this.pageId.value; + if (!this.sessionId.value) await this.loadSession(); + + const selectedBlockContext = this.selectedBlocks.value + .filter((b) => b.blockId) + .map((b) => ({ id: b.blockId, label: b.blockName || b.element })); + const selectedIds = this.selectedBlocks.value.map((b) => b.blockId).filter(Boolean); + const attachedImageData = this.imageData.value; + const attachedImageUrl = this.imagePreviewUrl.value; + this.clearImage(); + + const displayText = this.pendingDisplayText; + this.pendingDisplayText = null; + + const contextMeta: Record = {}; + if (selectedBlockContext.length) contextMeta.selectedBlockContext = selectedBlockContext; + if (attachedImageUrl) contextMeta.attachedImageUrl = attachedImageUrl; + if (displayText) contextMeta.displayText = displayText; + + const userMessage = buildLocalMessage("user", userText, contextMeta); + const assistantMessage = buildLocalMessage("assistant", "Thinking...", { status: "running" }); + this.messages.value.push(userMessage, assistantMessage); + this.pendingAssistantId.value = assistantMessage.id; + this.scrollToBottom(); + this.pageStreamContent.value = ""; + this.summaryContent.value = ""; + this.dispatcher.reset(); + this.isSubmitting.value = true; + + // The server edits the page authoritatively from draft_blocks — flush any + // unsaved canvas changes first so the turn (and its revert snapshot) starts + // from exactly what the user sees. + await this.pageStore.savePage(); + + try { + const result = await createResource({ + url: "builder.ai.api.run", + makeParams: () => ({ + prompt: userText, + page_id: this.pageId.value, + model: this.selectedModel.value, + session_id: this.sessionId.value, + ...(selectedIds.length ? { selected_block_ids: selectedIds } : {}), + ...(selectedBlockContext.length ? { selected_block_context: selectedBlockContext } : {}), + ...(attachedImageData ? { image_data: attachedImageData } : {}), + ...(displayText ? { display_text: displayText } : {}), + }), + }).submit(); + const response = result as { session_id?: string; status?: string; message?: string }; + if (response.session_id) this.sessionId.value = response.session_id; + } catch (error) { + await this.onError({ message: error instanceof Error ? error.message : "Request failed" }); + } + }; + + /** Revert an AI turn in ONE go: restore the page to the snapshot taken just before it + * — blocks, page data AND client scripts (created ones get unlinked, edited ones + * reverted) — and rewind the conversation, removing this message and everything after. + * The pre-turn snapshot is the single source of truth; there is no separate undo. */ + revertTurn = async (message: ChatMessage) => { + const snapshot: string | undefined = message.metadata?.revertSnapshot; + if (!snapshot || !this.sessionId.value) return; + const confirmed = await confirm( + "Revert this AI edit? The page (blocks and scripts) returns to how it was just before this turn, and this message and everything after it are removed from the chat. Your live page won't change until you publish.", + ); + if (!confirmed) return; + // 1. Rewind the conversation server-side (delete this turn + everything after). + await createResource({ url: "builder.ai.api.revert_to_message" }) + .submit({ session_id: this.sessionId.value, message_id: message.id }) + .catch(() => null); + // 2. Restore the page draft + scripts from the pre-turn snapshot. restore_snapshot + // re-applies blocks, page data and the client-script set/content, then re-fetches + // the page (which refreshes activePageScripts), so scripts revert in the same step. + await this.pageStore.restoreSnapshot(snapshot); + // 3. Reload the (now truncated) chat — restoreSnapshot doesn't touch the session. + await this.loadSession(); + this.scrollToBottom(); + }; + + selectBlockById = (blockId: string) => { + const block = this.dispatcher.findBlockInTree(blockId); + if (!block) return; + this.canvasStore.selectBlock(block, null, true, true); + }; + + openScriptByName = (scriptName: string) => { + this.builderStore.openClientScript = scriptName; + }; + + async mount() { + if (this.pageId.value) + attachAIChatListeners(this.builderStore.realtime, this.pageId.value, this.handlers); + createResource({ + url: "builder.ai.api.get_ai_models", + auto: true, + onSuccess: (data: AIProvider[]) => { + this.availableModels.value = data; + }, + }); + // Costs display in the site's currency (converted from USD at a cached + // daily rate); until this resolves they show as USD. + createResource({ url: "builder.ai.api.get_ai_cost_currency", auto: true, onSuccess: setCostCurrency }); + await this.loadSession(); + await this.maybeRunInitialPrompt(); + } + + /** Auto-run a prompt handed off from the dashboard chat (an @page mention: + * "change the hero on @Home" navigates here with ?ai_prompt=…). Opens the chat + * tab, submits once a model is available, and strips the query so a refresh + * doesn't resubmit. */ + private async maybeRunInitialPrompt() { + const initial = this.route.query.ai_prompt as string | undefined; + if (!initial || this.isUnsavedPage.value) return; + this.builderStore.leftPanelActiveTab = "Chat"; + const { ai_prompt, ...rest } = this.route.query; + router.replace({ query: rest }); + // Wait briefly for the model list (mount fetches it async); bail if none. + for (let i = 0; i < 40 && !this.selectedModel.value; i++) { + await new Promise((r) => setTimeout(r, 100)); + } + if (!this.selectedModel.value) return; + this.prompt.value = initial; + await nextTick(); + this.submitPrompt(); + } + + unmount() { + if (this.pageId.value) + detachAIChatListeners(this.builderStore.realtime, this.pageId.value, this.handlers); + this.batchTracker.stopAll(); + } +} diff --git a/frontend/src/components/AIDebugPanel.vue b/frontend/src/components/AIDebugPanel.vue new file mode 100644 index 000000000..e56eed4ce --- /dev/null +++ b/frontend/src/components/AIDebugPanel.vue @@ -0,0 +1,293 @@ + + + diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue deleted file mode 100644 index 60376b359..000000000 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ /dev/null @@ -1,616 +0,0 @@ -