Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

3209 lines
148 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AI chat agent loop — wraps the existing /promptchain/ai/patch endpoint as a
tool callable from a streaming chat agent.
Provider routing mirrors /ai/patch:
- cloud=claude → Anthropic /v1/messages tool-use stream
- cloud=other → OpenAI-compat /v1/chat/completions tool-use stream
- local → OpenAI-compat /v1/chat/completions tool-use stream
(Qwen via Ollama works here — Ollama 0.4+ emits OpenAI-
shape tool_calls in streaming deltas)
When the model emits a tool_use for `apply_prompt_patch`, this module
dispatches to /promptchain/ai/patch over localhost — same endpoint the
legacy single-shot panel hits — so the patch flow's system prompt /
post-passes / model call remain byte-equivalent and zero-regression.
WS events on `promptchain_ai_stream`:
- agent_text : streamed text deltas from the chat agent
- agent_tool_call : tool_use detected (proposal_id, name, input)
- agent_tool_result : /ai/patch returned (proposal_id, applied)
- agent_done : end_turn or hop limit reached
- error : fatal (HTTP error, parse fail)
Internal block shape is Anthropic-style {type, text|id|name|input}; the
OpenAI streamer normalizes its tool_calls deltas to that shape so the loop
is provider-agnostic.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
import uuid
import aiohttp
from aiohttp import web
import server
from .api_utils import error_response, parse_json
from .shared import send_ws, HASH_RE
from . import ai_api # reuse _load_config, _emit, _active_requests, _cleanup_request
logger = logging.getLogger("promptchain.ai.agent")
routes = server.PromptServer.instance.routes
# ── tool surface ──────────────────────────────────────────────────────
_AGENT_TOOLS = [
{
"name": "list_model_styles",
"description": (
"List or look up the style templates configured for the "
"user's active model checkpoint. Returns the actual "
"templates in their ComfyUI installation — NOT generic "
"anime-style trivia from training data. Two use cases:\n"
" 1. Browse: 'what styles does this model have', 'what "
"anime styles are there', 'list cinematic ones' — pass "
"`filter` with the category word or leave empty.\n"
" 2. Identify a specific style by name: 'what's "
"hyperrealistic?', 'tell me about the cinematic style', "
"'does it have studio ghibli?' — pass `filter` with the "
"style name. When the filter matches a single template the "
"result includes its description text.\n"
"The `filter` param is a case-insensitive substring match "
"against BOTH the template's name AND its category, so "
"'hyperrealistic' matches the Hyperrealistic template (it's "
"in the Anime category, not a category itself). The tool "
"returns pre-formatted markdown that you MUST echo verbatim. "
"Do NOT call this to APPLY a style (that's "
"apply_prompt_patch)."
),
"input_schema": {
"type": "object",
"properties": {
"filter": {
"type": "string",
"description": (
"Optional case-insensitive substring filter. "
"Matches against BOTH name and category (e.g. "
"'anime' → all templates in Anime category; "
"'hyperrealistic' → the Hyperrealistic "
"template; 'cinematic' → matches both the "
"Cinematic category and any template named "
"Cinematic). Leave empty to list every template."
),
},
},
},
},
{
"name": "apply_prompt_patch",
"description": (
"Modify the user's current Stable-Diffusion prompt. Pass a short, "
"imperative natural-language `request` describing the change "
"(examples: 'add red socks', 'remove blur', 'increase red_socks "
"weight by 20%', 'switch outfit to delta red'). Returns the "
"patched prompt sections. Call this whenever the user asks for "
"any prompt change — adding, removing, reweighting, swapping a "
"character/outfit/pose/style. Do not call it for pure questions."
),
"input_schema": {
"type": "object",
"properties": {
"request": {
"type": "string",
"description": (
"An imperative description of EVERY prompt change "
"the user asked for in this turn. Include ALL "
"intents — outfit, pose, scene, character, "
"expression, style. If the user said 'tifa in a "
"cowboy outfit, leaning against a bar, expand "
"the bar scene', the request must cover the "
"cowboy outfit, the leaning pose, AND the scene "
"expansion — never drop an intent because it's a "
"different section than the focal one.\n\n"
"RULES:\n"
"1. DO NOT INVENT REPLACE / SWAP / REMOVE "
"OPERATIONS THE USER DID NOT NAME. 'add X' is "
"'add X', not 'add X, replace Y with X'. The "
"patch flow preserves existing section tokens by "
"default. Only emit a removal when the user "
"explicitly said 'remove Y' or 'replace Y with X'.\n"
"2. For SHORT user requests (1-6 words like "
"'spreading toes', 'barefoot', 'red socks', "
"'lying on a beach'), the request should be "
"'add <user's exact phrase> to // <section>'. "
"No swap clause, no interpretation, no "
"elaboration. Trust the user's literal words.\n"
"3. Character names use Danbooru canonical "
"first_last form (`cammy_white`, `tifa_lockhart`, "
"`chun-li`, `yuuki_(sao)`). Don't use franchise-"
"only forms like `cammy_(street_fighter)`.\n"
"4. Pose / scene / setting / expression concepts: "
"write as natural English phrases. Don't "
"underscore-join multi-word concepts you invent "
"— the patch flow canonicalizes phrases against "
"the full Danbooru tag database."
),
},
"character_queries": {
"type": "array",
"items": {"type": "string"},
"description": (
"REQUIRED whenever any character is in scope. "
"Canonical Danbooru first_last tags for every "
"character relevant to this turn — including "
"outfit-borrow sources. The downstream matcher "
"uses this to load each character's bio (curated "
"outfit, pose, anatomy slots). Without it, the "
"matcher has no bio loaded and outfit/pose "
"lookups by name silently fail.\n\n"
"Pass `[]` ONLY when no character is in scope "
"(generic scene, model question, conversational "
"turn).\n\n"
"Examples:\n"
" 'set up Cammy' → ['cammy_white']\n"
" 'cammy white sitting with legs up, "
"barefoot, hyperrealistic anime style' → "
"['cammy_white'] (character is Cammy)\n"
" 'cammy in pyra's outfit' → "
"['cammy_white', 'pyra_(xenoblade)']\n"
" 'cammy and tifa fighting' → "
"['cammy_white', 'tifa_lockhart']\n"
" node_prompt has '// Character: cammy_white', "
"user says 'switch to killer bee outfit' → "
"['cammy_white']\n"
" 'add red socks' (no character mentioned, "
"no // Character: in node_prompt) → []\n\n"
"WORDS LIKE 'killer bee', 'delta red', 'cannon "
"spike' are OUTFIT/POSE names — they belong "
"in `request` text, NEVER in character_queries."
),
},
},
"required": ["request"],
},
},
{
"name": "populate_inline_wildcards",
"description": (
"Fill the CURRENT node with a SET of options as inline "
"wildcards (the `::Label::body` switch/roll format), pulled "
"from the local character database or the model's style "
"templates. Use this whenever the user asks to add MANY items "
"at once by category or attribute rather than one specific "
"thing:\n"
" 'add all anime styles', 'fill this with photorealistic "
"styles' → source='styles', category=<the style category>.\n"
" 'add every street fighter character', 'add the blonde "
"street fighter girls as inline wildcards', 'add all "
"characters from nier' → source='characters' with the "
"matching filters.\n"
"Entries are APPENDED non-destructively to whatever is "
"already in the node, deduped by name. Do NOT use this to add "
"ONE named character or style — that is apply_prompt_patch. "
"Attribute filters map to database columns; leave a field "
"empty to not filter on it."
),
"input_schema": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["characters", "styles"],
"description": (
"'characters' to pull from the character database "
"(filter by series + appearance attributes); "
"'styles' to pull the model's style templates "
"(filter by category)."
),
},
"series": {
"type": "string",
"description": (
"source=characters only. Franchise/series filter, "
"natural form: 'street fighter', 'final fantasy', "
"'nier'. Substring match. Empty = all series."
),
},
"category": {
"type": "string",
"description": (
"source=styles only. Style category: 'anime', "
"'photorealistic', '3d styles', 'drawing', "
"'watercolor', 'lighting', etc. Empty = all."
),
},
"hair_color": {
"type": "string",
"description": (
"source=characters. e.g. 'blonde', 'black', "
"'red'. Empty = any."
),
},
"eye_color": {"type": "string", "description": (
"source=characters. e.g. 'blue', 'green'. Empty = any.")},
"hair_style": {"type": "string", "description": (
"source=characters. e.g. 'ponytail', 'twin_braids', "
"'bob'. Empty = any.")},
"body_type": {"type": "string", "description": (
"source=characters. BUILD only — 'muscular', 'slim', "
"'curvy', 'petite'. NEVER put gender here. Empty = any.")},
"gender": {"type": "string", "description": (
"source=characters. 'female' or 'male' — filters the "
"roster by character gender. Use this for 'the female "
"X characters' / 'male X'. Do NOT use body_type for "
"gender. Empty = both.")},
"include_outfit": {"type": "boolean", "description": (
"source=characters. Set TRUE when the user asks for "
"characters WITH their (default) outfits — appends "
"each character's canonical default outfit to the "
"body. Default false = appearance only.")},
},
"required": ["source"],
},
},
{
"name": "generate_subjects",
"description": (
"GENERATE novel, original subjects from a theme — NOT named "
"characters from the database. Use when the user asks to "
"invent/create/make-up people by a vibe or description rather "
"than pull existing ones:\n"
" 'give me 10 inline wildcards for 10 mature women with "
"random outfits', 'fill this with 6 cyberpunk mercenaries', "
"'make me an elegant noblewoman'.\n"
"Subjects are composed by sampling the appearance KB (hair, "
"eyes, build, marks) + outfits, grounded and varied. "
"Contrast: populate_inline_wildcards FETCHES named DB "
"characters/styles; apply_prompt_patch edits ONE thing; "
"generate_subjects INVENTS a set (or one) from a theme.\n"
"Translate the user's theme into a recipe: hard constraints "
"go in `fixed` (e.g. a 'mature woman' theme → "
"fixed={'body_type':'mature_female'}); the count and outfit "
"wish set `count` / `outfit_policy`. Default SFW — only touch "
"explicit content when the user explicitly asks."
),
"input_schema": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "How many subjects to generate (default 1).",
},
"subject_kind": {
"type": "string",
"description": (
"The noun for the subject: 'woman', 'man', "
"'warrior', 'mercenary'. Default 'woman'."
),
},
"fixed": {
"type": "object",
"description": (
"Hard appearance constraints every subject shares, "
"as {axis: value}. Axes: body_type, hair_color, "
"eye_color, hair_style, skin. e.g. a 'mature "
"woman' theme → {'body_type': 'mature_female'}; "
"'redheads' → {'hair_color': 'red'}. Leave empty "
"for a fully random theme."
),
},
"outfit_policy": {
"type": "string",
"description": (
"How to dress each subject. RULE: if the user "
"names ANY outfit type/aesthetic/category, use "
"'themed:<that word>' — even if they also say "
"'random'. 'random' only means 'pick freely' when "
"NO outfit word is given; it does NOT cancel a "
"named theme. Examples:\n"
" 'random lingerie outfits' → 'themed:lingerie' "
"(random selection WITHIN lingerie)\n"
" 'in goth outfits' → 'themed:goth'\n"
" 'wearing swimsuits' → 'themed:swimsuit'\n"
" 'with random outfits' / 'with outfits' → "
"'random' (no specific type named)\n"
" outfits not mentioned at all → 'random' "
"(the default)\n"
" 'no outfit' / 'just the face' → 'none'.\n"
"The theme word is passed straight through to a KB "
"lookup — use the user's word (lingerie, goth, "
"kimono, military, bikini, cyberpunk, ...)."
),
},
"mode": {
"type": "string",
"enum": ["inline_wildcards", "single"],
"description": (
"'inline_wildcards' = append N ::Label:: entries "
"to the node (use when the user says 'inline "
"wildcards' or asks for several). 'single' = one "
"subject as a plain prompt. Default "
"'inline_wildcards' when count>1, else 'single'."
),
},
},
"required": [],
},
},
{
"name": "list_inline_wildcards",
"description": (
"Fill the CURRENT node with a list of THINGS (not people) from "
"the knowledge base as inline wildcards: clothing, shoes, "
"lingerie, swimwear, hats, dresses, poses, expressions / "
"emotions, scenes, backgrounds, lighting, weather, actions, "
"and so on. Use for 'a list of N <things>' / 'N types of "
"<things>' / 'all the <things>' where the wildcard entries "
"themselves are the things.\n"
"Examples:\n"
" 'list of 10 lingerie outfits' -> what='lingerie'\n"
" '10 types of shoes' -> what='shoes'\n"
" 'add some angry expressions' -> what='angry expressions'\n"
" 'fill this with rooftop backgrounds' -> what='backgrounds'\n"
" 'a bunch of dynamic poses' -> what='poses'\n"
"DECISION RULE — what are the wildcard entries?\n"
" THINGS (clothes/poses/expressions/scenes/actions) -> this "
"tool.\n"
" Named characters (Cammy, every Street Fighter char) -> "
"populate_inline_wildcards (source=characters).\n"
" Style templates -> populate_inline_wildcards (source="
"styles).\n"
" PEOPLE invented from a theme (10 mature women) -> "
"generate_subjects.\n"
"Pass the category in the user's own words; a KB catalog "
"resolves it deterministically."
),
"input_schema": {
"type": "object",
"properties": {
"what": {
"type": "string",
"description": (
"What to list. For a plain category, the user's "
"word: 'shoes', 'lingerie', 'backgrounds', "
"'weather'. For a SPECIFIC described item, pass "
"the user's FULL description verbatim — do NOT "
"compress it to a category. The KB is ranked-"
"searched against these words, so detail picks the "
"right item.\n"
" 'a pose sitting with legs up showing bare feet, "
"focus on feet' -> what='pose sitting legs up "
"showing bare feet focus on feet' (NOT 'foot "
"poses').\n"
" 'an angry open-mouth shouting expression' -> "
"what='angry open-mouth shouting expression'.\n"
"Keep the descriptive words; they are the signal."
),
},
"count": {
"type": "integer",
"description": (
"How many entries to produce. Set it to the number "
"the user asked for: '5 poses' -> 5, 'a pose that "
"is X' / 'one foot pose' -> 1 (the single best "
"match). Omit it when no number is implied ('some "
"lingerie', 'foot poses') to get a default set."
),
},
},
"required": ["what"],
},
},
]
_AGENT_MACRO_VERBS_BLOCK = (
"Macro-verb shortcuts — when the user uses one of these verbs, "
"translate it into a focused tool `request` (don't refuse or ask "
"for clarification; pick a sensible default scope):\n"
"- 'expand' / 'elaborate' / 'add detail' on a section: tool call "
"with 'add 4-6 more canonical tags adjacent to the existing items "
"in // <section>; preserve the existing tags verbatim'.\n"
"- 'condense' / 'tighten' / 'simplify' / 'trim' a section: tool "
"call with 'reduce // <section> to its 3-5 most essential canonical "
"tags; drop redundant near-duplicates'.\n"
"- 'reword' / 'rephrase' / 'say it differently' a section: tool "
"call with 'replace the tokens in // <section> with semantically-"
"equivalent canonical tags conveying the same intent'.\n"
"- 'enrich' / 'add atmosphere' / 'polish': tool call with 'add "
"3-5 atmospheric tokens to // Style (e.g. cinematic_lighting, "
"volumetric_fog, rim_light, dramatic_shadows, moody_atmosphere) "
"AND 3-5 quality tokens to // Quality (e.g. masterpiece, "
"best_quality, highres, sharp_focus, detailed); preserve other "
"sections'.\n"
"- 'vary' / 'give me variations' / 'options for X' / 'show me "
"<n> takes': DO NOT call the tool. Instead, reply in chat with a "
"short numbered list (3 options unless the user named a different "
"count). For each option, give it a brief name and the 4-8 tags "
"you'd put in the section. The user will pick one in a follow-up "
"turn (e.g. 'go with option 1') — that follow-up IS when you call "
"the tool with the chosen option's tags. Do not modify the prompt "
"until the user picks.\n"
" Example reply for 'give me 3 sexy variations on a bunny outfit':\n"
" '1. **Classic Playboy** — black bunny suit, fishnet stockings, "
"bow tie, bunny ears, cuffs.\n"
" 2. **Latex Couture** — latex bunny suit, thigh high boots, "
"long gloves, choker, bunny ears.\n"
" 3. **Cute & Frilly** — pastel bunny dress, frilly tutu, bunny "
"ears headband, ribbon, knee socks.\n"
" Which one would you like to try?'\n\n"
)
# Question-shape detection — same set of phrases the agent system
# prompt uses to decide "answer in text vs call the tool". Bio preload
# only fires when the message looks like a question; on edit / setup
# requests, the bio block is unhelpful noise that shifts the agent's
# paraphrasing and regresses multi-character scenarios.
_QUESTION_OPENERS = (
"what is", "what's", "what are", "what color", "what kind",
"who is", "who's", "who are",
"do you know", "tell me about", "describe",
"which", "how", "why", "when", "where", "is",
)
def _looks_like_question(text: str) -> bool:
if not text:
return False
t = text.strip().lower()
if not t:
return False
if t.endswith("?"):
return True
return any(t.startswith(opener) for opener in _QUESTION_OPENERS)
def _filter_bios_for_agent_preload(bios: list[dict],
user_text: str) -> list[dict]:
"""Strict filter on match-characters output for agent-side
knowledge preload. Drops single-token-prefix matches (e.g.
`night` -> `night_angel`) that polluted the system prompt in the
pre-T4 raw-text preflight era.
Keeps a bio only when its canonical tag's bare form (parens
stripped) appears as a multi-word phrase in the user message OR
its display name appears as a multi-word phrase. Single-word
matches survive only when the canonical tag itself is single-
token (e.g. `mythra` matches a tag whose bare form is `mythra`).
Filters input: bios already deduped and matched by the matcher;
we just trim noise."""
if not bios or not user_text:
return bios
text_lc = user_text.lower()
def _multi_token_in_text(name: str) -> bool:
n = (name or "").lower().strip()
if not n:
return False
# Remove parenthetical disambiguation suffix.
n = re.sub(r"\s*\([^)]*\)", "", n).strip()
if not n:
return False
# Normalize separators to space then check phrase membership.
phrase = re.sub(r"[\s\-_]+", " ", n).strip()
if not phrase:
return False
if " " in phrase:
return phrase in re.sub(r"[\s\-_]+", " ", text_lc)
# Single-token canonical (e.g. `mythra`) — accept word-bounded
# match in user text.
return bool(re.search(
r"(?<![A-Za-z0-9])" + re.escape(phrase) + r"(?![A-Za-z0-9])",
text_lc,
))
out: list[dict] = []
for b in bios:
tag = (b.get("tag") or "").strip()
display = (b.get("display") or "").strip()
if _multi_token_in_text(tag) or _multi_token_in_text(display):
out.append(b)
return out
def _build_agent_bio_block(bios: list[dict]) -> str:
"""Render filtered bios as a 'Character knowledge' system-prompt
block. APPEARANCE FACTS ONLY — base_tags (hair, eyes, build,
distinguishing features). Outfits are intentionally NOT surfaced
here: the patch flow already gets them via `character_queries`
when the agent dispatches a tool call, AND surfacing both
characters' outfits in the agent prompt during borrow scenarios
bloats the system prompt and shifts paraphrasing behavior, which
regressed the borrow harness.
Empty string when bios is empty so the prompt stays lean on
edit / generic-chat turns."""
if not bios:
return ""
lines = [
"Character knowledge (from local database — use as the "
"AUTHORITATIVE source for any factual question about these "
"characters' APPEARANCE; do NOT contradict these facts with "
"model world knowledge):",
]
for b in bios[:5]: # cap to 5 to avoid system-prompt bloat
tag = (b.get("tag") or "").strip()
display = (b.get("display") or "").strip() or tag
series = (b.get("series") or "").strip()
base_tags = (b.get("base_tags") or "").strip()
line = f"- {display}"
if series:
line += f" ({series})"
line += f" [canonical: {tag}]"
if base_tags:
line += f": {base_tags}"
lines.append(line)
return "\n".join(lines) + "\n\n"
def _agent_system_prompt(node_ctx: dict, bios: list[dict] | None = None) -> str:
node_prompt = (node_ctx or {}).get("node_prompt") or ""
# extra_verbs gates the macro-verb shortcuts block. Off by default so
# the simple-edit user gets a leaner system prompt — the 8B Qwen
# showed measurable noise increase when the macro block was always
# on, and most users only need add/remove/swap verbs anyway.
extra_verbs = bool((node_ctx or {}).get("extra_verbs"))
macro_block = _AGENT_MACRO_VERBS_BLOCK if extra_verbs else ""
# Character knowledge block — surfaces bio facts for any character
# the user mentioned in this turn. Lets the agent answer factual
# questions ("what color hair does cammy have?") from the local DB
# instead of model world knowledge, which is unreliable on 8B.
# Only fires when at least one bio matched — no noise on edits or
# generic chat. For tool calls, the agent's `character_queries`
# field still drives bio resolution in `_call_patch_internal`; this
# block exists for the text-response path.
bio_block = _build_agent_bio_block(bios or [])
# /no_think disables Qwen3.x reasoning blocks for the chat-agent role.
# Tool-routing decisions are simple — we don't need <think> traces
# for them, and reasoning models burn 2-8K tokens per call on those
# blocks, dominating turn latency. The patch model still gets its
# own /no_think directive in ai_api._patch_system_prompt.
return (
"/no_think\n"
+ bio_block
+ "You are an in-editor AI assistant helping a user iterate on "
"Stable-Diffusion prompts inside ComfyUI's PromptChain node. "
"Your job is to read the user's natural-language requests and, "
"when they want a prompt change, call the `apply_prompt_patch` "
"tool with a focused imperative `request` string distilled from "
"their message.\n\n"
"Conversational rules:\n"
"- Reply in 1-2 short sentences, never a wall of text.\n"
"- After a tool call, summarize what changed in plain English. Do "
"NOT re-list the diff — the UI shows it. One sentence is enough.\n"
"- If the user's message ends with `?` or starts with explicit "
"question phrases (`what is`, `who is`, `do you know`, `tell me "
"about`, `which`, `how`, `why`), reply in text without calling "
"the `apply_prompt_patch` tool. Otherwise default to CALLING "
"`apply_prompt_patch` — the user is almost always trying to "
"edit the prompt.\n"
"- EXCEPTION: questions about MODEL STYLES go to "
"`list_model_styles` (which fetches the user's actual "
"configured templates), not plain-text replies. Triggers: "
"'what styles does this model have', 'what anime styles are "
"there', 'list available styles', 'any cinematic ones', "
"'what's hyperrealistic', 'tell me about studio ghibli'. The "
"tool's `filter` param does case-insensitive substring match "
"against BOTH name AND category, so pass whatever style word "
"the user mentioned — 'anime' (category match), 'hyperrealistic' "
"(name match), 'cinematic' (matches both). The tool returns "
"PRE-FORMATTED markdown that you MUST echo verbatim — keep "
"the `- ` dashes and `**bold**` markers, do not strip them; "
"they render as real bullets in the chat. Do not add training-"
"data style names (Shoujo, Seinen, Slice of Life are not real "
"templates), do not drop entries, do not reorder. 'apply the "
"X style' is an edit, not a query — that still goes to "
"apply_prompt_patch.\n"
"- A short message containing a character name + any setup verb "
"(`set up X`, `give me X`, `make X`, `mythra`, `cammy in delta "
"red`) IS a tool-call request. Do not ask 'do you mean X' or "
"'would you like me to' — just call the tool. The downstream "
"matcher validates names against the character database; you "
"don't need to verify them yourself.\n"
"- If a name is genuinely ambiguous (multiple known characters "
"share it AND the user gave no franchise context), THEN ask. "
"Otherwise prefer action over caution.\n\n"
"Tool-call rules:\n"
"- Call `apply_prompt_patch` AT MOST ONCE per turn.\n"
"- The `request` you pass is fed to a separate, narrow tag-editing "
"model. Phrase it as an imperative the way a junior prompt editor "
"would understand: 'add red socks', 'increase red_socks weight by "
"20%', 'remove blur, motion_blur', 'switch character to "
"cammy_white'. Do NOT pass open-ended phrasings like 'make it "
"better' or 'fix the issues'.\n"
"- When naming a character, use the Danbooru canonical full-name "
"tag form: first_last (`cammy_white`, `tifa_lockhart`, `chun-li`, "
"`yuuki_(sao)`). Do NOT use franchise-only forms like "
"`cammy_(street_fighter)` — those rarely match the character "
"database. If you don't know the canonical form, use the most "
"specific name you do know plus the franchise.\n"
"- Pass the user's request through to the tool's `request` field "
"in natural language. Don't rewrite it into a `setting: X; style: "
"Y` template — that template loses verbs and modifiers that don't "
"fit the slot names. The patch flow handles natural language "
"directly.\n"
"- For pose, action, scene, setting, expression, gaze, framing, "
"and any non-character concept where you are NOT certain a real "
"Danbooru tag exists with that exact spelling, write the concept "
"as a natural English phrase. Do NOT join multi-word concepts "
"with underscores to make them look tag-shaped — the downstream "
"tag-editing model has the full Danbooru tag database and "
"canonicalizes phrases against it. An underscore-joined "
"fake-tag-shape you invent gets echoed verbatim and then dropped "
"as untraceable, which means the real canonical never lands in "
"the output. Canonical character tags and weighted forms stay in "
"canonical form per the rules above; everything else is natural "
"language.\n"
"- Pass `character_queries` whenever a CHARACTER is in scope — "
"either named directly by the user OR present in node_prompt's "
"`// Character:` header. The matcher needs the bio loaded to "
"fuzzy-match outfit/pose names against that character's curated "
"options. Without it, 'switch to killer bee outfit' has no bio "
"to find Killer Bee in.\n"
" Examples:\n"
" 'set up Cammy' → ['cammy_white']\n"
" 'cammy in pyra's outfit' (borrow) → ['cammy_white', "
"'pyra_(xenoblade)']\n"
" 'cammy and tifa fighting' → ['cammy_white', 'tifa_lockhart']\n"
" node_prompt has '// Character: cammy_white', user says "
"'switch to killer bee outfit' → ['cammy_white'] (existing "
"character stays in scope so matcher can find her Killer Bee "
"outfit). Pass the canonical from the `// Character:` header.\n"
" Words like 'killer bee', 'delta red', 'cannon spike' are "
"OUTFIT/POSE names — they belong in `request` text, NEVER in "
"character_queries.\n"
" Skip character_queries only when no character is in scope at "
"all (fresh prompt with no character + user describing a scene "
"without naming anyone, or pure-question turns).\n"
"- Never call the tool a second time within one turn unless the "
"user explicitly asked for two distinct changes.\n\n"
+ macro_block
+ f"Current prompt in the node:\n<node_prompt>\n{node_prompt}\n</node_prompt>"
)
def _router_system_prompt(node_ctx: dict) -> str:
"""Prompt for the tool-routing pass. Tight focus: decide which tool
(if any) to call and with what arguments. No narration guidance —
that belongs to `_assistant_system_prompt`. Bios are intentionally
omitted; the router doesn't need character knowledge to pick a tool,
and the patch flow runs its own preflight on character_queries."""
node_prompt = (node_ctx or {}).get("node_prompt") or ""
extra_verbs = bool((node_ctx or {}).get("extra_verbs"))
macro_block = _AGENT_MACRO_VERBS_BLOCK if extra_verbs else ""
return (
"/no_think\n"
"You are the tool-router for an in-editor AI assistant helping a "
"user iterate on Stable-Diffusion prompts inside ComfyUI's "
"PromptChain node. Your job: read the user's latest message in "
"context of the chat history, decide whether to call a tool, "
"and emit the tool call with focused arguments. If no tool is "
"needed (general conversational message, factual question about "
"a character's appearance), emit a brief 1-2 sentence text reply "
"instead.\n\n"
"Routing rules:\n"
"- Edit requests (add, remove, swap, reweight, switch character / "
"outfit / pose / scene / style) → apply_prompt_patch.\n"
"- 'apply / try / use / go with / pick / let's do X' is an EDIT "
"→ apply_prompt_patch, NOT list_model_styles. Even when X is a "
"style name the user just looked up, the user now wants to "
"APPLY it.\n"
"- Questions about MODEL STYLES ('what styles does this model "
"have', 'what anime styles are there', 'what's hyperrealistic', "
"'tell me about studio ghibli') → list_model_styles. The tool's "
"`filter` param does case-insensitive substring match against "
"BOTH name AND category; pass whatever style word the user "
"mentioned.\n"
"- Generic chat or factual questions about a character's "
"appearance ('what color hair does cammy have') → no tool, "
"short text reply.\n"
"- A short message containing a character name + setup verb "
"(`set up X`, `give me X`, `mythra`, `cammy in delta red`) IS "
"a tool-call request. Don't ask for clarification — call "
"apply_prompt_patch. The downstream matcher validates names; "
"you don't need to verify them yourself.\n"
"- Deictic references ('that one', 'it', 'lets try that', "
"'use that', 'apply that') after discussing a specific style / "
"character / outfit MUST be resolved against the chat history "
"AND treated as an APPLY request → apply_prompt_patch. The "
"`request` field must NAME the referent explicitly (e.g. "
"'switch style to Hyperrealistic'), never echo the literal "
"deictic.\n"
"- When in doubt between a tool and just chatting, prefer "
"apply_prompt_patch — most user messages in this editor are "
"edit requests.\n\n"
"Args-construction rules for apply_prompt_patch are in the tool's "
"schema description — follow them. Two additional routing rules:\n"
"- Call apply_prompt_patch AT MOST ONCE per turn unless the user "
"explicitly asked for two distinct changes.\n"
"- When calling apply_prompt_patch, you MUST populate "
"`character_queries` with every named character in the user's "
"request AND every character already present in the node prompt's "
"`// Character:` header. Missing characters break outfit/pose "
"matching downstream. Only pass `[]` when there's genuinely no "
"character in scope (generic edits like 'add red socks' to an "
"anonymous prompt). Examples are in the tool schema.\n\n"
+ macro_block
+ f"Current prompt in the node:\n<node_prompt>\n{node_prompt}\n</node_prompt>"
)
def _combined_system_prompt(node_ctx: dict,
bios: list[dict] | None = None,
history_summary: str = "",
has_image: bool = False) -> str:
"""Single-persona prompt for the A/B comparison against router+assistant.
Mirrors the Copilot/Continue.dev pattern: one system prompt covers
BOTH the routing decision (which tool, if any) AND the narration of
the tool result in the same conversation. Tools are attached to
every call; the model decides tool-vs-text on the fly.
Args-construction rules for apply_prompt_patch live in the tool's
schema (the model sees them via function-call spec)."""
node_prompt = (node_ctx or {}).get("node_prompt") or ""
extra_verbs = bool((node_ctx or {}).get("extra_verbs"))
macro_block = _AGENT_MACRO_VERBS_BLOCK if extra_verbs else ""
bio_block = _build_agent_bio_block(bios or [])
summary_block = ""
if history_summary:
summary_block = (
"Conversation so far (older turns, condensed — these edits are "
"ALREADY reflected in the current prompt, do not re-apply them):\n"
+ history_summary
+ "\n\n"
)
image_block = ""
if has_image:
image_block = (
"IMAGE ATTACHED — the user has attached an image to this turn.\n"
"- If they asked a question about it ('who is this?', 'what "
"character/outfit/style is this?'), answer in chat. Name the "
"character AND franchise if you recognize them; say plainly if "
"you are unsure rather than guessing confidently.\n"
"- If they asked you to recreate / make a prompt for it (or "
"attached it with no instruction), call apply_prompt_patch with "
"a `request` that DESCRIBES what you see: the character by name "
"and franchise if recognized (e.g. 'Cammy from Street Fighter'), "
"otherwise plain visual description (hair, body, clothing, pose, "
"setting, art style). Naming a known character lets the system "
"ground it to canonical tags; do NOT invent canonical tags "
"yourself — describe in natural language and let the patch step "
"resolve them.\n"
"- If they use the image as a REFERENCE for one specific aspect "
"('do her in this pose', 'use this angle/composition/lighting/"
"framing', 'same pose as this'), extract ONLY that named aspect "
"from the image and put just that in the request. Do NOT copy the "
"reference person's identity, face, hair, eyes, or clothing — the "
"subject stays whoever the user is already working on.\n"
"- If the user refers to a subject by pronoun ('her', 'him', "
"'them') but the current prompt has no character and none was "
"established earlier in the chat, ASK who they mean instead of "
"inventing one.\n\n"
)
return (
"/no_think\n"
+ bio_block
+ summary_block
+ image_block
+ "You are an in-editor AI assistant helping a user iterate on "
"Stable-Diffusion prompts inside ComfyUI's PromptChain node. "
"Reply in 1-2 short sentences, friendly and concise. Use "
"markdown when listing items (`- **Name**` bullets, `**bold**` "
"for emphasis).\n\n"
"Routing — when to call which tool vs chat directly:\n"
"- Edit requests (add, remove, swap, reweight, switch "
"character/outfit/pose/scene/style) → apply_prompt_patch.\n"
"- 'apply / try / use / go with / pick / let's do X' is an "
"EDIT → apply_prompt_patch, NOT list_model_styles, even when "
"X is a style name the user just looked up.\n"
"- Questions about MODEL STYLES ('what styles does this model "
"have', 'what anime styles', 'what's hyperrealistic', 'tell "
"me about studio ghibli') → list_model_styles. Pass `filter` "
"with whatever style word the user mentioned.\n"
"- Requests to add MANY items at once by category/attribute → "
"populate_inline_wildcards. 'add all anime styles', 'fill this "
"with photorealistic styles' → source='styles'. 'add every "
"street fighter character', 'add the blonde street fighter "
"girls', 'add all characters from nier' → source='characters' "
"with series + appearance filters. This is for SETS; a single "
"named character/style is still apply_prompt_patch.\n"
"- Requests to INVENT original subjects from a theme (not named "
"DB characters) → generate_subjects. 'give me 10 inline "
"wildcards for mature women with random outfits', 'make me a "
"cyberpunk mercenary', 'fill this with 6 elegant noblewomen'. "
"Map the theme to `fixed` constraints (e.g. mature woman → "
"fixed={'body_type':'mature_female'}), set count + "
"outfit_policy. KEY DISTINCTION: populate_inline_wildcards "
"FETCHES existing NAMED characters/styles; generate_subjects "
"INVENTS new ones from a vibe. 'street fighter characters' = "
"populate; 'mature women' / 'cyberpunk mercenaries' = generate.\n"
"- ANY request to add/make an INLINE WILDCARD of a THING (pose, "
"expression, scene, background, lighting, weather, action, "
"clothing, shoes, ...) → list_inline_wildcards. The words "
"'inline', 'inline wildcard', 'another inline', 'a second "
"inline', 'wildcard for X' ALL route here — even for ONE entry. "
"Pass `what`=the user's description verbatim and (if they named a "
"number) `count`. DO NOT use apply_prompt_patch for this and DO "
"NOT hand-write a `::Label::body` string yourself — that's the "
"tool's job; apply_prompt_patch cannot insert wildcard entries "
"and silently no-ops.\n"
" 'add a second inline for presenting a foot while standing, leg "
"extended, foot focus' → list_inline_wildcards(what='presenting a "
"foot while standing, leg extended, foot focus', count=1).\n"
" '10 lingerie outfits' / 'some angry expressions' / 'rooftop "
"backgrounds' / 'dynamic poses' → list_inline_wildcards too.\n"
"KEY: THINGS → list_inline_wildcards; PEOPLE → generate_subjects "
"(invent) or populate_inline_wildcards (named chars).\n"
"- Factual questions about a character's appearance ('what "
"color hair does cammy have') → answer directly using the "
"Character knowledge block, no tool.\n"
"- Generic chat → reply directly, no tool.\n"
"- A short message containing a character name + setup verb "
"(`set up X`, `give me X`, `mythra`, `cammy in delta red`) "
"IS a tool-call request — call apply_prompt_patch without "
"asking for clarification.\n"
"- Deictic references ('that one', 'it', 'lets try that') "
"after discussing a specific style/character MUST be resolved "
"against chat history AND treated as an APPLY request. The "
"`request` field must NAME the referent explicitly.\n"
"- When in doubt, prefer apply_prompt_patch — most user "
"messages in this editor are edit requests.\n\n"
"Narrating tool_result blocks (look at the most recent in the "
"conversation):\n"
"- list_model_styles with MULTIPLE styles: introduce briefly "
"(`This model has N styles:`), then a bulleted list of just "
"the `name` fields as bold. Don't add descriptions per item, "
"don't add style names that aren't in the result, don't "
"reorder. End with one short line offering to apply one.\n"
"- list_model_styles with a SINGLE style: introduce by name "
"in bold, then ONE sentence describing what the style does "
"using the `description` field. Don't invent details.\n"
"- list_model_styles EMPTY: say no matching styles are "
"configured.\n"
"- apply_prompt_patch result: summarize what changed in ONE "
"sentence. Don't re-list the diff — the UI shows it.\n\n"
"Args-construction rules for apply_prompt_patch are in its "
"tool schema. Two more rules:\n"
"- Call apply_prompt_patch AT MOST ONCE per turn unless the "
"user explicitly asked for two distinct changes.\n"
"- When calling apply_prompt_patch, you MUST populate "
"`character_queries` with every named character in the user's "
"request AND every character already present in the node "
"prompt's `// Character:` header. Missing characters break "
"outfit/pose matching downstream. Only pass `[]` when there's "
"genuinely no character in scope (generic edits like 'add red "
"socks' to an anonymous prompt).\n\n"
+ macro_block
+ f"Current prompt in the node:\n<node_prompt>\n{node_prompt}\n</node_prompt>"
)
def _assistant_system_prompt(node_ctx: dict,
bios: list[dict] | None = None) -> str:
"""Prompt for the narration pass. The assistant has NO tools and
receives the user message + the router's `tool_use` + the `tool_result`
as canonical Anthropic-shaped blocks in `history` (matches what
Qwen3 / Claude / OpenAI all expect for tool exchanges). Bios are
surfaced here (not in the router) because narration is where the
assistant needs to answer factual questions about characters; the
patch flow already resolves bios for tool dispatch."""
node_prompt = (node_ctx or {}).get("node_prompt") or ""
bio_block = _build_agent_bio_block(bios or [])
return (
"/no_think\n"
+ bio_block
+ "You are an in-editor AI assistant helping a user iterate on "
"Stable-Diffusion prompts inside ComfyUI's PromptChain node. "
"Reply in 1-2 short sentences, friendly and concise. Use "
"markdown when listing items (e.g. `- **Name**` bullets, "
"`**bold**` for emphasis).\n\n"
"Narrating tool results (look at the most recent tool_result "
"block in the conversation):\n"
"- list_model_styles with MULTIPLE styles: introduce briefly "
"(`This model has N styles:`), then a bulleted list of just "
"the `name` fields as bold (`- **90s Anime**`). Do NOT add "
"descriptions per item, do NOT add style names that aren't in "
"the result, do NOT reorder. End with one short line offering "
"to apply one.\n"
"- list_model_styles with a SINGLE style: introduce by name in "
"bold, then ONE sentence describing what the style does using "
"the `description` field. Use the description verbatim or "
"summarize from it — do NOT invent details from outside the "
"result.\n"
"- list_model_styles with EMPTY array: say no matching styles "
"are configured for this model.\n"
"- apply_prompt_patch result: summarize what changed in ONE "
"sentence. Don't re-list the diff — the UI shows it."
+ f"\n\nCurrent prompt in the node:\n<node_prompt>\n{node_prompt}\n</node_prompt>"
)
# ── loop config ───────────────────────────────────────────────────────
_AGENT_MAX_HOPS = 6
_AGENT_MAX_TOKENS = 2048 # chat replies are short; reasoning is the patch model's job
_AGENT_CONNECT_TIMEOUT = 10
_AGENT_READ_TIMEOUT = 300
# ── history compaction ────────────────────────────────────────────────
# A long chat ferries its entire canonical message list (text + every
# tool_use/tool_result block) back to the model each turn (see
# _run_agent_loop). Left unbounded it eventually overruns the model
# window — Ollama runs at num_ctx=32768 (see _stream_openai_compat_with_tools),
# so the local case is the binding constraint. We watermark: below budget,
# send verbatim; above it, drop the OLDEST turns and fold them into a
# mechanical summary carried in the system prompt. Safe to drop the old
# tool bodies because their effect is already baked into node_prompt, which
# is rebuilt and sent fresh every turn.
_HISTORY_BUDGET_LOCAL = 16000 # ~half of Ollama's 32768, leaving system+output room
_HISTORY_BUDGET_CLOUD = 120000 # bounds cost on large-window cloud models
_HISTORY_RECENT_TARGET = 0.55 # trim recent-tail down to this fraction of budget
_HISTORY_SUMMARY_MAX_CHARS = 3000 # cap the running summary itself
def _emit_agent(request_id: str, event: str, **payload):
"""Mirror ai_api._emit but with arbitrary payload keys (proposal_id,
name, input, etc.). Goes through the same WS channel."""
msg = {
"request_id": request_id,
"event": event,
"t": time.time(),
**payload,
}
send_ws("promptchain_ai_stream", msg)
# ── message + tool shape converters ───────────────────────────────────
# Internal history is Anthropic-shaped (assistant content is a list of
# {type:"text"|"tool_use"|"tool_result", ...}). The OpenAI tool-use API
# wants role=assistant with `tool_calls` and role=tool with `tool_call_id`.
# Convert at the wire boundary so the loop stays shape-agnostic.
def _to_openai_messages(system: str, anthropic_messages: list[dict]) -> list[dict]:
out: list[dict] = []
if system:
out.append({"role": "system", "content": system})
for msg in anthropic_messages:
role = msg.get("role")
content = msg.get("content")
if isinstance(content, str):
out.append({"role": role, "content": content})
continue
if not isinstance(content, list):
out.append({"role": role, "content": ""})
continue
if role == "user":
text_parts: list[str] = []
tool_results: list[dict] = []
image_parts: list[dict] = []
for block in content:
bt = block.get("type")
if bt == "text":
text_parts.append(block.get("text") or "")
elif bt == "tool_result":
tool_results.append(block)
elif bt == "image":
# Anthropic base64 image → OpenAI image_url data-URL.
src = block.get("source") or {}
if src.get("type") == "base64" and src.get("data"):
image_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:{src.get('media_type', 'image/png')};base64,{src['data']}"
},
})
for tr in tool_results:
out.append({
"role": "tool",
"tool_call_id": tr.get("tool_use_id") or "",
"content": tr.get("content") or "",
})
joined = "\n".join(p for p in text_parts if p)
if image_parts:
# Multimodal user turn: OpenAI wants content as a parts list.
parts: list[dict] = []
if joined:
parts.append({"type": "text", "text": joined})
parts.extend(image_parts)
out.append({"role": "user", "content": parts})
elif joined:
out.append({"role": "user", "content": joined})
elif not tool_results:
out.append({"role": "user", "content": ""})
continue
if role == "assistant":
text_parts = []
tool_calls: list[dict] = []
for block in content:
bt = block.get("type")
if bt == "text":
text_parts.append(block.get("text") or "")
elif bt == "tool_use":
tool_calls.append({
"id": block.get("id") or "",
"type": "function",
"function": {
"name": block.get("name") or "",
"arguments": json.dumps(block.get("input") or {}, ensure_ascii=False),
},
})
entry: dict = {"role": "assistant", "content": "\n".join(text_parts) if text_parts else ""}
if tool_calls:
entry["tool_calls"] = tool_calls
out.append(entry)
continue
# fall-through (system rare, etc.)
out.append({"role": role, "content": json.dumps(content)})
return out
def _tools_for_openai(anthropic_tools: list[dict]) -> list[dict]:
return [{
"type": "function",
"function": {
"name": t.get("name"),
"description": t.get("description") or "",
"parameters": t.get("input_schema") or {"type": "object"},
},
} for t in anthropic_tools]
# ── streaming with tools (Anthropic) ──────────────────────────────────
async def _stream_claude_with_tools(
request_id: str, api_key: str, model: str,
system: str, messages: list[dict],
*, tools: list[dict] | None = None,
) -> dict:
"""Stream a single Claude turn with optional tool support. Returns:
{"stop_reason": str, "content": [...content_blocks...]}
`content` is the assembled list of text + tool_use blocks in order.
Streams text deltas to WS as `agent_text` events live.
`tools` defaults to the module's _AGENT_TOOLS. Pass `[]` for a
narration-only call with no tool surface (assistant pass)."""
if tools is None:
tools = _AGENT_TOOLS
payload = {
"model": model,
"max_tokens": _AGENT_MAX_TOKENS,
"system": system,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
blocks_by_index: dict[int, dict] = {} # index -> {type, _text|_input_json, ...}
final_blocks: list[dict] = []
stop_reason: str | None = None
parse_failures = 0
timeout = aiohttp.ClientTimeout(connect=_AGENT_CONNECT_TIMEOUT, sock_read=_AGENT_READ_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json=payload,
) as resp:
logger.info("agent_stream[%s] HTTP %d", request_id, resp.status)
if resp.status != 200:
err_body = await resp.text()
raise RuntimeError(f"Claude HTTP {resp.status}: {err_body[:300]}")
# readline() is line-delimited; iterating resp.content yields
# arbitrary chunks that can split or merge SSE events.
while True:
raw = await resp.content.readline()
if not raw:
break
line = raw.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str:
continue
try:
evt = json.loads(data_str)
except Exception:
parse_failures += 1
continue
evt_type = evt.get("type")
if evt_type == "content_block_start":
idx = evt.get("index", 0)
block = evt.get("content_block") or {}
blocks_by_index[idx] = {
"type": block.get("type"),
"id": block.get("id"),
"name": block.get("name"),
"_text": "",
"_input_json": "",
}
elif evt_type == "content_block_delta":
idx = evt.get("index", 0)
delta = evt.get("delta") or {}
blk = blocks_by_index.get(idx)
if blk is None:
continue
if delta.get("type") == "text_delta":
text = delta.get("text", "")
blk["_text"] += text
_emit_agent(request_id, "agent_text", content=text, partial=True)
elif delta.get("type") == "input_json_delta":
blk["_input_json"] += delta.get("partial_json", "")
elif evt_type == "content_block_stop":
idx = evt.get("index", 0)
blk = blocks_by_index.pop(idx, None)
if blk is None:
continue
if blk["type"] == "text":
final_blocks.append({"type": "text", "text": blk["_text"]})
elif blk["type"] == "tool_use":
try:
parsed_input = json.loads(blk["_input_json"] or "{}")
except Exception as e:
logger.warning(
"agent_stream[%s] tool_use input parse fail: %s | raw=%r",
request_id, e, blk["_input_json"][:200],
)
parsed_input = {}
final_blocks.append({
"type": "tool_use",
"id": blk["id"],
"name": blk["name"],
"input": parsed_input,
})
_emit_agent(
request_id, "agent_tool_call",
proposal_id=blk["id"], name=blk["name"], input=parsed_input,
)
elif evt_type == "message_delta":
sr = (evt.get("delta") or {}).get("stop_reason")
if sr:
stop_reason = sr
elif evt_type == "message_stop":
return {"stop_reason": stop_reason, "content": final_blocks}
elif evt_type == "error":
err_msg = (evt.get("error") or {}).get("message") or "stream error"
raise RuntimeError(err_msg)
if parse_failures:
logger.info("agent_stream[%s] %d parse failures", request_id, parse_failures)
return {"stop_reason": stop_reason or "end_turn", "content": final_blocks}
# ── streaming with tools (OpenAI-compat / Ollama) ─────────────────────
async def _stream_openai_compat_with_tools(
request_id: str, base_url: str, model: str,
system: str, messages: list[dict],
*, api_key: str | None = None, is_ollama: bool = False,
tools: list[dict] | None = None,
) -> dict:
"""Stream a single tool-aware turn via OpenAI /v1/chat/completions.
Used for cloud-non-claude AND local Ollama (which serves OpenAI-shape
tool_calls deltas as of 0.4+). `messages` is in Anthropic shape; we
convert at the wire boundary. Returns Anthropic-shaped result so the
agent loop stays provider-agnostic.
Tool-call streaming: deltas come on `choices[0].delta.tool_calls[]`
with stable `index` values; the first chunk for an index carries
{id, type, function: {name}}, subsequent chunks carry partial
`function.arguments`. We accumulate per-index then JSON-parse on
finish_reason."""
if tools is None:
tools = _AGENT_TOOLS
payload = {
"model": model,
"messages": _to_openai_messages(system, messages),
"stream": True,
"max_tokens": _AGENT_MAX_TOKENS,
}
if tools:
payload["tools"] = _tools_for_openai(tools)
if is_ollama:
# Disable Ollama-side reasoning blocks for the chat-agent role —
# tool-routing decisions don't need <think>, and reasoning models
# often emit tool_calls AFTER long think blocks which inflates
# latency. The patch flow's separate Ollama call still gets
# whatever think setting the patch path uses.
payload["think"] = False
payload["options"] = {"num_ctx": 32768}
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
text_buffer: list[str] = []
# Per-index tool-call accumulator: {index: {id, name, args_json}}.
tool_calls_by_index: dict[int, dict] = {}
finish_reason: str | None = None
parse_failures = 0
timeout = aiohttp.ClientTimeout(connect=_AGENT_CONNECT_TIMEOUT, sock_read=_AGENT_READ_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
url = f"{base_url}/chat/completions"
async with session.post(url, json=payload, headers=headers) as resp:
logger.info("agent_stream_openai[%s] HTTP %d url=%s", request_id, resp.status, url)
if resp.status != 200:
err_body = await resp.text()
raise RuntimeError(f"chat-agent HTTP {resp.status}: {err_body[:300]}")
# readline() is line-delimited; iterating resp.content yields
# arbitrary chunks that can split or merge SSE events.
while True:
raw = await resp.content.readline()
if not raw:
break
line = raw.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
break
if not data_str:
continue
try:
evt = json.loads(data_str)
except Exception:
parse_failures += 1
continue
choices = evt.get("choices") or []
if not choices:
continue
choice = choices[0]
delta = choice.get("delta") or {}
text_delta = delta.get("content") or ""
if text_delta:
text_buffer.append(text_delta)
_emit_agent(request_id, "agent_text", content=text_delta, partial=True)
tcs = delta.get("tool_calls") or []
for tc in tcs:
idx = tc.get("index", 0)
slot = tool_calls_by_index.setdefault(idx, {
"id": "", "name": "", "args_json": "",
})
if tc.get("id"):
slot["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
slot["name"] = fn["name"]
if fn.get("arguments"):
slot["args_json"] += fn["arguments"]
fr = choice.get("finish_reason")
if fr:
finish_reason = fr
if parse_failures:
logger.info("agent_stream_openai[%s] %d parse failures", request_id, parse_failures)
# Assemble final blocks in Anthropic shape, ordered text-then-tool_use
# per OpenAI's wire convention.
final_blocks: list[dict] = []
if text_buffer:
final_blocks.append({"type": "text", "text": "".join(text_buffer)})
for idx in sorted(tool_calls_by_index.keys()):
slot = tool_calls_by_index[idx]
try:
parsed_input = json.loads(slot["args_json"] or "{}")
except Exception as e:
logger.warning(
"agent_stream_openai[%s] tool args parse fail: %s | raw=%r",
request_id, e, slot["args_json"][:200],
)
parsed_input = {}
proposal_id = slot["id"] or f"toolu_{uuid.uuid4().hex[:12]}"
final_blocks.append({
"type": "tool_use",
"id": proposal_id,
"name": slot["name"],
"input": parsed_input,
})
_emit_agent(
request_id, "agent_tool_call",
proposal_id=proposal_id, name=slot["name"], input=parsed_input,
)
# Full raw-response dump on the shared debug channel — the build/patch
# paths get rich `dbg` traces in ai_api, but this agent tool-stream
# path only had an 80-char preview (agent_hop). When the model
# misroutes (wrong tool, empty/garbage args, or a leaked <think> block
# from an Ollama that ignored think:False), that preview hides the
# WHY. Dumping the full text + each tool call's raw args here makes a
# single re-run conclusive regardless of where the turn derails.
raw_text = "".join(text_buffer)
if ai_api._THINK_OPEN_RE.search(raw_text):
ai_api.dbg.info(
"agent[%s] LEAKED <think> block in agent response content — "
"think:False not honored by this Ollama build", request_id,
)
tool_dump = "; ".join(
f"{tool_calls_by_index[i].get('name') or '?'}("
f"{ai_api._trunc(tool_calls_by_index[i].get('args_json') or '', 600)})"
for i in sorted(tool_calls_by_index.keys())
) or "(none)"
ai_api.dbg.info(
"agent[%s] raw response: finish_reason=%s | tools=%s | text=%s",
request_id, finish_reason, tool_dump,
ai_api._trunc(raw_text, 2000) or "(empty)",
)
# Map OpenAI finish_reason → Anthropic stop_reason.
if finish_reason == "tool_calls":
stop_reason = "tool_use"
elif finish_reason == "stop":
stop_reason = "end_turn"
else:
stop_reason = finish_reason or "end_turn"
return {"stop_reason": stop_reason, "content": final_blocks}
# ── n-gram extraction for server-side bios preflight ──────────────────
# Python port of svelte-src/lib/ai-patch-helpers.js (extractNGrams +
# synthesizeParenVariants). Behavior must stay aligned with the client
# version — the legacy single-shot path runs preflight client-side; the
# chat agent paraphrases the user's intent so we must run preflight
# again, server-side, on the agent's distilled `request` string.
_WEIGHT_RE = re.compile(r"\(\s*([^():]+?)\s*:\s*[\d.]+\s*\)")
_POSSESSIVE_RE = re.compile(r"'s\b", re.IGNORECASE)
_NON_WORD_RE = re.compile(r"[^\w\s()'\\-]")
_PREP_RE = re.compile(r"\s+(?:from|in|of|for|as)\s+", re.IGNORECASE)
def _synthesize_paren_variants(text: str) -> list[str]:
if not text:
return []
variants: list[str] = []
for m in _PREP_RE.finditer(text):
left_text = text[:m.start()].strip()
right_text = text[m.end():].strip()
if not left_text or not right_text:
continue
left_words = [w for w in re.split(r"\s+", re.sub(r"[^\w\s'\\-]", " ", left_text)) if w]
right_words = [w for w in re.split(r"\s+", re.sub(r"[^\w\s'\\-]", " ", right_text)) if w]
for nl in range(1, min(3, len(left_words)) + 1):
name = " ".join(left_words[-nl:])
for rl in range(1, min(3, len(right_words)) + 1):
series = " ".join(right_words[:rl])
variants.append(f"{name} ({series})")
return variants
def _extract_ngrams(text: str) -> list[str]:
if not text:
return []
cleaned = _WEIGHT_RE.sub(r"\1", text)
cleaned = _POSSESSIVE_RE.sub("", cleaned)
cleaned = _NON_WORD_RE.sub(" ", cleaned)
words = [w for w in re.split(r"\s+", cleaned) if w]
out: set[str] = set()
for n in range(1, 5):
for i in range(len(words) - n + 1):
phrase = " ".join(words[i:i + n]).strip()
if not phrase:
continue
if n == 1 and len(phrase) < 3:
continue
out.add(phrase)
for variant in _synthesize_paren_variants(text):
out.add(variant)
return list(out)
async def _preflight_bios(
request: web.Request, agent_request: str, node_prompt: str,
*, latest_user_text: str = "", user_text_override: str | None = None,
franchise_hint_text: str = "",
) -> list[dict]:
"""Run match-characters server-side. N-grams come from THREE sources
so character mentions are caught regardless of where they appear:
- agent_request: the chat agent's distilled tool input (often has
canonical `cammy_(street_fighter)` form when the agent inferred
the character)
- latest_user_text: the user's most recent message verbatim (catches
names like 'cammy white' that the agent may have paraphrased
away — e.g. interpreting 'white' as a color modifier)
- node_prompt: existing characters already in the prompt
Union'd so a hit in any source flows the curated bio into the patch.
`user_text_override`: when set, sent as the `user_text` field in the
match-characters payload (drives the OUTFIT picker / POSE picker
server-side) regardless of what fed the n-grams. Used by the
character_queries-driven path so character matching stays focused
on the agent's queries while outfit/pose picking still sees the
user's full natural-language request."""
sources = [agent_request, latest_user_text or "", node_prompt or ""]
all_ngrams: set[str] = set()
for src in sources:
for ng in _extract_ngrams(src):
all_ngrams.add(ng)
ngrams = list(all_ngrams)
if not ngrams:
return []
host = request.url.host or "127.0.0.1"
port = request.url.port or 8188
url = f"http://{host}:{port}/promptchain/tag-builder/match-characters"
payload = {
"tokens": ngrams,
"user_text": (user_text_override if user_text_override is not None else agent_request),
"node_prompt": node_prompt or "",
# Franchise hint text — used ONLY by the matcher's stage-3/4
# franchise-preference tiebreaker. Distinct from n-gram source
# so it doesn't expand the matcher's token set (which would
# cause bare names like `rin`/`miku`/`kagamine` from the user's
# full message to over-match unrelated characters). Distinct
# from `user_text` (which is scoped to the agent's distilled
# request to avoid outfit-picker bleed).
"latest_user_text": franchise_hint_text or "",
}
timeout = aiohttp.ClientTimeout(connect=_AGENT_CONNECT_TIMEOUT, sock_read=30)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload) as resp:
if resp.status != 200:
logger.warning("preflight_bios HTTP %d", resp.status)
return []
data = await resp.json()
matched = data.get("matched") or []
return matched if isinstance(matched, list) else []
except Exception as e:
logger.warning("preflight_bios failed: %s", e)
return []
# ── /ai/patch internal dispatch ───────────────────────────────────────
async def _call_patch_internal(
request: web.Request,
node_ctx: dict,
user_request: str,
chat_request_id: str,
*,
latest_user_text: str = "",
current_user_text: str = "",
character_queries: list[str] | None = None,
) -> dict:
"""POST to /promptchain/ai/patch over localhost so the patch endpoint
sees the same request shape it does from the panel.
Bios resolution: when the agent provided `character_queries`, run
match-characters over the joined query string. When it didn't, no
bio is loaded — the patch flow's no-bio path takes over. We do NOT
fall back to scanning raw user text: the LLM is the character
resolver, and a regex/prefix-match fallback re-creates exactly the
false positives the character_queries pivot was meant to eliminate
(e.g. 'micro bikini' → micro_uzi_(girls'_frontline) prefix-match)."""
node_prompt = node_ctx.get("node_prompt") or ""
t_pre = time.perf_counter()
cleaned_queries = [q.strip() for q in (character_queries or []) if q and q.strip()]
if cleaned_queries:
# LLM-driven path: agent listed every character relevant to this
# turn. Match all queries via a single joined-string preflight so
# the existing matcher (n-grams + prefix-match) resolves each
# name independently.
#
# user_text_override carries ONLY the current turn's request
# (the agent's paraphrased imperative) so the OUTFIT picker
# sees in-turn phrases like 'in her sf6 outfit' but does NOT
# see prior-turn history that could fuzzy-match to outfit
# names ('what street fighter...' → 'Street Fighter 6' outfit).
joined = " | ".join(cleaned_queries)
agent_bios = await _preflight_bios(
request, joined, "", latest_user_text="",
user_text_override=user_request,
franchise_hint_text=latest_user_text,
)
logger.info(
"agent_patch[%s] bios_preflight=%.2fs hits=%d source=character_queries=%r",
chat_request_id, time.perf_counter() - t_pre,
len(agent_bios), cleaned_queries,
)
else:
# Agent didn't dispatch character_queries — no bio. The patch
# flow's no-bio path handles the request (model uses its own
# knowledge for the character). If a bio actually was needed,
# this surfaces as a quality drop in the harness rather than
# silently mis-injecting a phantom character via prefix match.
agent_bios = []
logger.info(
"agent_patch[%s] bios_preflight=%.2fs hits=0 source=no_queries "
"(agent did not pass character_queries; no fallback)",
chat_request_id, time.perf_counter() - t_pre,
)
# When the agent provided character_queries, that list is
# authoritative — the LLM read the user's intent. Don't union with
# the client-side raw-text preflight (node_ctx.bios), because that
# path is regex/prefix-driven and false-positives on garment words
# ("micro bikini" → micro_uzi_(girls'_frontline)). Without queries,
# we fall back to client-side bios as before.
# Outfit-borrow augmentation: when the user request mentions
# `<name>'s outfit|clothes|attire|...`, that character is an OUTFIT
# SOURCE, not a subject. The agent typically (and correctly) leaves
# such names out of character_queries — they aren't appearing in
# the image. But the patch flow needs the source character's
# outfit slot data to perform the borrow. Pull missing source bios
# from node_ctx.bios (the client-side raw-text preflight) when
# their name matches a borrow pattern AND they aren't already in
# agent_bios. Narrowly scoped — only fires on explicit `'s outfit`
# phrasing, not bare name mentions, so the false-positive risk that
# justified killing client-side bios union is sidestepped.
if cleaned_queries:
# Pattern captures the words PRECEDING a possessive `'s <outfit-
# word>`. Greedy match would grab too much (e.g. `change outfit
# to chun-li` for "change outfit to chun-li's outfit"), so we
# instead scan suffix windows of 1-3 tokens against client_bios
# — same approach the natlang side's
# `_match_foreign_character_outfit` uses.
_borrow_pattern = re.compile(
r"([A-Za-z][\w\s\-]*?)['’]s\s+"
r"(outfit|clothes|attire|costume|getup|gear|uniform|"
r"look|set|wardrobe|garb|wear)\b",
re.IGNORECASE,
)
borrow_candidates: list[str] = []
for m in _borrow_pattern.finditer(user_request or ""):
phrase = m.group(1).strip().lower()
tokens = re.findall(r"[\w\-]+", phrase)
# Try last 1, 2, 3 tokens (chun-li / to chun-li /
# outfit to chun-li). Last-token-first because character
# names tend to be the rightmost in `<verb> ... <name>'s`.
for window in (1, 2, 3):
if window > len(tokens):
break
candidate = " ".join(tokens[-window:])
if len(candidate) >= 2:
borrow_candidates.append(candidate)
if borrow_candidates:
existing_tags = {(b or {}).get("tag", "").lower() for b in agent_bios}
client_bios = node_ctx.get("bios") or []
for cb in client_bios:
if not isinstance(cb, dict):
continue
tag = (cb.get("tag") or "").lower()
if not tag or tag in existing_tags:
continue
display = (cb.get("display") or "").lower()
tag_norm = re.sub(r"[\s_\-]+", " ", tag).strip()
display_norm = re.sub(r"[\s_\-]+", " ", display).strip()
matched_via: str | None = None
for cand in borrow_candidates:
cand_norm = re.sub(r"[\s_\-]+", " ", cand).strip()
if not cand_norm:
continue
if (cand_norm == tag_norm or
cand_norm == display_norm or
(len(cand_norm) >= 3 and
(cand_norm in tag_norm or
cand_norm in display_norm))):
matched_via = cand
break
if matched_via:
augmented = dict(cb)
augmented["_outfit_source_only"] = True
agent_bios.append(augmented)
existing_tags.add(tag)
logger.info(
"agent_patch[%s] outfit-borrow: added %s as "
"outfit-source bio (matched name %r in request)",
chat_request_id, tag, matched_via,
)
seen: set[str] = set()
bios: list[dict] = []
sources = agent_bios if cleaned_queries else (
agent_bios + (node_ctx.get("bios") or [])
)
for b in sources:
tag = (b or {}).get("tag") or ""
if tag in seen:
continue
seen.add(tag)
bios.append(b)
payload = {
"request_id": f"{chat_request_id}-patch-{uuid.uuid4().hex[:6]}",
"node_prompt": node_prompt,
"user_request": user_request,
# Original user message — patch flow uses this to recover intents
# the agent's paraphrasing may have dropped (cowboy outfit, leaning
# pose, etc.). user_request is the agent's distilled imperative;
# latest_user_text is the source of truth for what the user asked.
"latest_user_text": latest_user_text or "",
# Current-turn user message ONLY (no multi-turn concat). Used for
# outfit-name scan and other current-turn intent classification
# where prior-turn phrases would pollute matching ('cowboy outfit'
# from last turn matching against this turn's 'french maid').
"current_user_text": current_user_text or latest_user_text or "",
"bios": bios,
# Plumb character_queries through so the patch endpoint can
# detect multi-character ADD transitions (2+ queries while the
# existing node_prompt has 0-1 // Character: sections) and
# route through a dedicated compose path that bypasses
# hybrid/rails dispatch. Without this signal the patch flow
# has to infer chars from the agent's prose request, which
# gets unreliable on multi-char edits.
"character_queries": cleaned_queries or [],
"tag_format": node_ctx.get("tag_format") or "spaces",
"model_hash": node_ctx.get("model_hash") or "",
"prompt_style": node_ctx.get("prompt_style") or "tags",
"is_standalone_main": bool(node_ctx.get("is_standalone_main")),
"prompt_state": node_ctx.get("prompt_state"),
}
host = request.url.host or "127.0.0.1"
port = request.url.port or 8188
url = f"http://{host}:{port}/promptchain/ai/patch"
timeout = aiohttp.ClientTimeout(connect=_AGENT_CONNECT_TIMEOUT, sock_read=_AGENT_READ_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload) as resp:
# On non-200 the patch endpoint may return text/plain (default
# aiohttp 500 page) instead of JSON. Read the body as text
# first and parse JSON only when content-type allows; surface
# whichever shape we got as a useful error instead of crashing
# with ContentTypeError on a 500.
raw_body = await resp.text()
if resp.status != 200:
snippet = (raw_body or "").strip()[:300] or f"HTTP {resp.status}"
raise RuntimeError(f"patch HTTP {resp.status}: {snippet}")
try:
data = json.loads(raw_body)
except Exception as e:
raise RuntimeError(f"patch returned non-JSON body: {e}: {raw_body[:200]}")
return data
def _dispatch_list_model_styles(tool_input: dict, node_ctx: dict,
*, grounding: dict | None = None) -> dict:
"""Resolve a list_model_styles tool call against the user's configured
style templates. Returns:
{
summary: str, # legacy pre-rendered markdown (combined-call path)
data: { # structured shape (router+assistant path)
styles: [{name, category, id, description}, ...],
filter, count, arch,
},
count, filter, arch,
}
Caller chooses which shape to feed back as tool_result content.
Caller owns WS emission + logging.
`grounding`: pass when the caller already resolved arch/family (the CLI
e2e harness needs this — `ai_api._build_grounding` can't see model
config without folder_paths). Production omits it and lets us compute.
"""
from . import prompts as _prompts
model_hash = (node_ctx or {}).get("model_hash") or ""
if grounding is None:
grounding = ai_api._build_grounding(model_hash) if model_hash else {}
arch = (grounding.get("architecture") or "").strip() or None
family = (grounding.get("family") or "").strip() or None
templates = _prompts.list_prompts(
architecture=arch, family=family,
model_hash=model_hash or None,
)
raw_rows = [p for p in templates if (p.get("name") or "").strip()]
# Accept the legacy `category` field in case cached tool-use blocks
# still carry it — same case-insensitive substring match against
# BOTH name AND category, so 'hyperrealistic' matches the
# Hyperrealistic template (which is in the Anime category).
raw_filter = tool_input.get("filter") or tool_input.get("category") or ""
flt = raw_filter.strip().lower()
if flt:
raw_rows = [
p for p in raw_rows
if flt in (p.get("name") or "").lower()
or flt in (p.get("category") or "").lower()
]
rows = [
{
"name": (p.get("name") or "").strip(),
"category": (p.get("category") or "").strip(),
"id": (p.get("id") or "").strip(),
"text": p.get("text") or "",
}
for p in raw_rows
]
# Build structured `data` rows alongside the legacy summary so the
# router+assistant path (Stage 3) can consume clean JSON without
# having to re-parse the markdown summary.
data_styles: list[dict] = []
for r in rows:
try:
pos_tokens, _neg = ai_api._parse_style_template_text(r["text"])
except Exception:
pos_tokens = []
data_styles.append({
"name": r["name"],
"category": r["category"],
"id": r["id"],
"description": ", ".join(pos_tokens),
})
if not rows:
header = "No matching style templates configured"
if flt:
header += f" for '{flt}'"
header += f" on this model (arch={arch or 'unknown'})."
summary = header
elif len(rows) == 1:
r = rows[0]
try:
pos_tokens, _neg = ai_api._parse_style_template_text(r["text"])
except Exception:
pos_tokens = []
desc = ", ".join(pos_tokens) if pos_tokens else (
"No description text available."
)
cat_str = (
f" (in the **{r['category']}** category)" if r["category"] else ""
)
intro = (
f"**{r['name']}**{cat_str} — yes, this template is "
f"configured for your model."
)
outro = (
"Reply with the intro line above, then a one-paragraph "
"plain-English summary of what the style does based on this "
"description (do not list the raw tokens verbatim): "
f'"{desc}"'
)
summary = intro + "\n\n[" + outro + "]"
else:
scope = f"{flt} style" if flt else "style"
bullets = [
f"- **{r['name']}**"
+ (f" ({r['category']})" if r.get("category") and not flt else "")
for r in rows
]
intro = (
f"This model has {len(rows)} {scope} "
f"template{'' if len(rows) == 1 else 's'}:"
)
outro = (
"Reply to the user with the intro line above followed by EXACTLY "
"this bullet list, VERBATIM markdown (keep the `- ` dashes and "
"the `**bold**` markers — they render as a real bulleted list "
"in the chat UI). Do not add, remove, reorder, or paraphrase "
"names. End with a one-line offer to apply one."
)
summary = intro + "\n" + "\n".join(bullets) + "\n\n[" + outro + "]"
return {
"summary": summary,
"data": {
"styles": data_styles,
"filter": flt,
"count": len(rows),
"arch": arch,
},
"count": len(rows),
"filter": flt,
"arch": arch,
}
_POPULATE_FILTER_KEYS = (
"series", "hair_color", "eye_color", "hair_style",
"body_type", "breast_size", "ass_size", "category",
"gender", "sex",
)
def _dispatch_populate_inline_wildcards(tool_input: dict, node_ctx: dict,
*, grounding: dict | None = None) -> dict:
"""Resolve a populate_inline_wildcards tool call against the current
node's content. Deterministic — no LLM. Returns the populate() result
dict: {content, summary, added, skipped, total_entries, source}.
`grounding`: pass when arch/family already resolved (CLI harness); prod
omits it so the populate core computes it from model_hash."""
from . import inline_wildcard_populate as _iwp
source = (tool_input.get("source") or "").strip()
filters = {
k: tool_input[k].strip()
for k in _POPULATE_FILTER_KEYS
if isinstance(tool_input.get(k), str) and tool_input[k].strip()
}
existing = (node_ctx or {}).get("node_prompt") or ""
model_hash = (node_ctx or {}).get("model_hash") or ""
include_outfit = bool(tool_input.get("include_outfit"))
# The node's prompt_style drives the body format: "natural" -> prose,
# anything else ("tags") -> danbooru tag bodies. Auto-detected; no AI
# input needed.
fmt = "natlang" if (node_ctx or {}).get("prompt_style") == "natural" else "tags"
return _iwp.populate(
source, existing_content=existing, filters=filters,
model_hash=model_hash, grounding=grounding,
include_outfit=include_outfit, fmt=fmt,
)
def _dispatch_generate_subjects(tool_input: dict, node_ctx: dict) -> dict:
"""Resolve a generate_subjects tool call. Deterministic KB sampling —
no LLM here (the agent already produced the recipe). Returns the
generate_subjects() result dict."""
from . import generate_subjects as _gs
count = tool_input.get("count")
try:
count = int(count) if count is not None else 1
except (TypeError, ValueError):
count = 1
mode = (tool_input.get("mode") or "").strip()
if mode not in ("inline_wildcards", "single"):
mode = "single" if count <= 1 else "inline_wildcards"
fixed = tool_input.get("fixed")
if not isinstance(fixed, dict):
fixed = {}
fmt = "natlang" if (node_ctx or {}).get("prompt_style") == "natural" else "tags"
return _gs.generate_subjects(
count=count,
subject_kind=(tool_input.get("subject_kind") or "woman").strip() or "woman",
fixed={k: str(v) for k, v in fixed.items() if v},
outfit_policy=(tool_input.get("outfit_policy") or "random").strip(),
mode=mode,
existing_content=(node_ctx or {}).get("node_prompt") or "",
fmt=fmt,
)
def _dispatch_list_inline_wildcards(tool_input: dict, node_ctx: dict) -> dict:
"""Resolve a list_inline_wildcards call against the KB catalog. Pure
deterministic resolve+emit; the agent only passed a category phrase."""
from . import kb_list as _kb
what = (tool_input.get("what") or "").strip()
count = tool_input.get("count")
try:
count = int(count) if count not in (None, "") else None
except (TypeError, ValueError):
count = None
existing = (node_ctx or {}).get("node_prompt") or ""
fmt = "natlang" if (node_ctx or {}).get("prompt_style") == "natural" else "tags"
return _kb.list_items(what, count=count, fmt=fmt, existing_content=existing)
def _format_tool_result_summary(patch_resp: dict) -> str:
"""Compact summary of the patch result. The chat agent only needs
enough to write a 1-sentence reply to the user; dumping the whole
sections JSON wastes context."""
sections = patch_resp.get("sections") or []
out_chars = len(patch_resp.get("output_text") or "")
if not sections:
# Empty sections + a real output = a from-scratch build, NOT a no-op.
# Saying "no-op" here makes the agent narrate failure to the user.
if out_chars > 0:
return (f"Patch applied; prompt rebuilt ({out_chars} chars). "
"The change succeeded.")
return "Patch applied; no changes were needed."
lines = ["Patch applied. Sections:"]
for s in sections:
header = s.get("header") or "(unnamed)"
polarity = "negative" if s.get("is_negative") else "positive"
action = "removed" if s.get("is_removal") else "changed"
if s.get("body_text"):
preview = s["body_text"][:140]
lines.append(f"- {header} ({polarity}, {action}): {preview}")
else:
tokens = s.get("tokens") or []
sample = ", ".join(tokens[:6]) + (" …" if len(tokens) > 6 else "")
lines.append(f"- {header} ({polarity}, {action}, {len(tokens)} tokens): {sample}")
out_chars = len(patch_resp.get("output_text") or "")
lines.append(f"Total prompt length: {out_chars} chars.")
return "\n".join(lines)
# ── agent loop ────────────────────────────────────────────────────────
def _extract_current_user_text(history: list[dict]) -> str:
"""Return the text of the most recent user-role message in history.
Used for current-turn intent classification (outfit-name scan etc.)
where multi-turn _extract_recent_text would pollute with stale
phrases from prior turns ('cowboy outfit' from a previous request
matching against this turn's 'french maid')."""
for msg in reversed(history or []):
if (msg.get("role") or "") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
chunks = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
t = block.get("text") or ""
if t:
chunks.append(t)
joined = "\n".join(chunks).strip()
if joined:
return joined
return ""
def _extract_recent_text(history: list[dict], max_messages: int = 6) -> str:
"""Pull text from the last N messages (any role) for bios preflight
n-gram extraction. Multi-turn lookup-then-confirm flows hide the
canonical character name in the assistant's prior reply (e.g. agent
answered 'Cammy (Street Fighter) has blonde pigtails' on turn 1) —
when the user then says 'yes', we need to scan the assistant text
too so the bios preflight still catches Cammy.
Skips tool_result blocks (they're patch dispatch responses, not
human-meaningful text). Returns text joined newline-separated."""
chunks: list[str] = []
n = 0
for msg in reversed(history or []):
if n >= max_messages:
break
n += 1
content = msg.get("content")
if isinstance(content, str):
chunks.append(content)
continue
if not isinstance(content, list):
continue
for block in content:
if block.get("type") == "text":
t = block.get("text") or ""
if t:
chunks.append(t)
# tool_use input's `request` field is also a useful signal —
# it's what the agent decided to send to the patch model
elif block.get("type") == "tool_use":
inp = block.get("input") or {}
req = inp.get("request") or ""
if req:
chunks.append(req)
return "\n".join(chunks)
def _estimate_message_tokens(msg: dict) -> int:
"""Rough token count for one canonical message. chars/4 is the usual
English-text heuristic; exactness doesn't matter here — we only need a
stable signal for the compaction watermark."""
content = msg.get("content")
if isinstance(content, str):
return len(content) // 4
if not isinstance(content, list):
return 0
chars = 0
for b in content:
if not isinstance(b, dict):
continue
bt = b.get("type")
if bt == "text":
chars += len(b.get("text") or "")
elif bt == "tool_use":
chars += len(json.dumps(b.get("input") or {}))
elif bt == "tool_result":
c = b.get("content")
chars += len(c if isinstance(c, str) else json.dumps(c))
return chars // 4
def _summarize_dropped_messages(msgs: list[dict]) -> list[str]:
"""Mechanical (non-LLM) recap of turns being dropped from the model's
context. Keeps only the load-bearing signal: what the user asked and
which edits got applied. Narration, tool_result bodies, and lookup
calls are omitted — they carry no state the rebuilt node_prompt doesn't
already convey."""
lines: list[str] = []
for m in msgs:
role = m.get("role") or ""
content = m.get("content")
if isinstance(content, str):
blocks = [{"type": "text", "text": content}]
elif isinstance(content, list):
blocks = content
else:
continue
for b in blocks:
if not isinstance(b, dict):
continue
bt = b.get("type")
if bt == "text" and role == "user":
txt = (b.get("text") or "").strip().replace("\n", " ")
if txt:
lines.append(f'- You: "{txt[:140]}"')
elif bt == "tool_use" and b.get("name") == "apply_prompt_patch":
req = ((b.get("input") or {}).get("request") or "").strip().replace("\n", " ")
if req:
lines.append(f'- Applied edit: "{req[:140]}"')
return lines
def _compact_history(
history: list[dict], summary: str, prov: dict,
) -> tuple[list[dict], str]:
"""Watermark compaction. Returns (history_to_send, updated_summary).
Below budget: history unchanged, summary unchanged. Above budget: drop
the oldest turns up to a clean user-text boundary (so the retained tail
is a self-contained, role-alternating conversation with no orphaned
tool_use/tool_result), and append a mechanical recap of the dropped
turns to the running summary. The summary is injected into the system
prompt by the caller, NOT back into history — that keeps role
alternation and tool pairing intact."""
if not history:
return history, summary
# Keyless providers (local Ollama / llama.cpp) get the tight budget;
# only cloud providers with a key have the large window.
is_local = bool(prov.get("is_ollama")) or not prov.get("api_key")
budget = _HISTORY_BUDGET_LOCAL if is_local else _HISTORY_BUDGET_CLOUD
total = sum(_estimate_message_tokens(m) for m in history)
if total <= budget:
return history, summary
# User-text boundaries are the only safe cut points: a real user prompt,
# never a tool_result-carrying user turn.
bounds = []
for i, m in enumerate(history):
if (m.get("role") or "") != "user":
continue
c = m.get("content")
has_text = isinstance(c, str) or (
isinstance(c, list)
and any(isinstance(b, dict) and b.get("type") == "text" for b in c)
)
if has_text:
bounds.append(i)
if len(bounds) <= 1:
# Only the current turn exists as a boundary — nothing safe to drop.
return history, summary
target = int(budget * _HISTORY_RECENT_TARGET)
bound_set = set(bounds)
cut = bounds[-1] # worst case: keep only the current user turn onward
acc = 0
for i in range(len(history) - 1, -1, -1):
acc += _estimate_message_tokens(history[i])
if i in bound_set and acc <= target:
cut = i # keep as much as fits; loops downward → smallest qualifying i
if cut <= 0:
return history, summary
dropped, kept = history[:cut], history[cut:]
new_lines = _summarize_dropped_messages(dropped)
if new_lines:
joined = "\n".join(new_lines)
summary = f"{summary}\n{joined}".strip() if summary else joined
if len(summary) > _HISTORY_SUMMARY_MAX_CHARS:
summary = summary[-_HISTORY_SUMMARY_MAX_CHARS:]
nl = summary.find("\n") # drop the now-partial leading line
if nl != -1:
summary = summary[nl + 1:]
return kept, summary
# ── image-turn helpers ─────────────────────────────────────────────
_IMAGE_REVERSE_PROMPT_INSTRUCTION = (
"Create a Stable-Diffusion prompt that recreates this image. Identify "
"the character and franchise by name if you recognize them, then call "
"apply_prompt_patch describing the subject, outfit, pose, setting, and "
"art style."
)
def _set_last_user_text(history: list[dict], text: str) -> None:
"""Replace (or insert) the text of the most recent user message."""
for msg in reversed(history):
if (msg.get("role") or "") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
msg["content"] = text
return
if isinstance(content, list):
for b in content:
if isinstance(b, dict) and b.get("type") == "text":
b["text"] = text
return
content.insert(0, {"type": "text", "text": text})
return
def _attach_images_to_last_user(history: list[dict], image_blocks: list[dict]):
"""Append Anthropic image blocks to the most recent user message's
content (for this model call only). Returns the mutated message so the
caller can strip the blocks back out before persisting, or None."""
for msg in reversed(history):
if (msg.get("role") or "") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
content = [{"type": "text", "text": content}] if content else []
msg["content"] = content
if isinstance(content, list):
content.extend(image_blocks)
return msg
return None
return None
def _extract_doc_from_uploads(image_hashes: list[str]) -> str | None:
"""Recover the COMPILED prompt that actually rendered the first uploaded
image carrying ComfyUI metadata. `extract_prompts_from_file` traces the
sampler's conditioning back to the PromptChain node feeding it and reads
its pcrCompiledOutput — i.e. the resolved prompt, not the editable source.
The compiled output is what we want: a node's raw `inputs.prompt` may hold
unresolved inline-wildcard libraries (`::Style::…`) or be one piece of a
multi-node chain, neither of which is what produced the image."""
from . import chat_uploads
from .load_image_prompts import extract_prompts_from_file
for h in image_hashes:
p = chat_uploads.resolve_upload_path(h)
if not p:
continue
pos, neg = extract_prompts_from_file(str(p))
if pos:
return f"{pos}\n\nNegative Prompt:\n{neg}" if neg else pos
return None
def _doc_to_sections(doc: str) -> list[dict]:
"""Render a recovered prompt doc as proposal-card diff sections: the
positive body as prose, the negative as removable-style chips (matching
how the patch flow shapes negatives for the card)."""
parts = re.split(r"(?im)^\s*Negative Prompt:\s*$", doc, maxsplit=1)
pos = parts[0].strip()
neg = parts[1].strip() if len(parts) > 1 else ""
sections: list[dict] = []
if pos:
sections.append({
"header": "From image", "body_text": pos,
"is_negative": False, "is_removal": False,
})
if neg:
tokens = [t.strip() for t in re.split(r"[,\n]", neg) if t.strip()]
sections.append({
"header": "Negative", "tokens": tokens,
"is_negative": True, "is_removal": False,
})
return sections
def _metadata_prompt_response(request_id: str, history: list[dict],
history_summary: str, doc: str,
mode: str) -> dict:
"""Build the chat response for the metadata short-circuit: an assistant
turn carrying a proposal whose output_text IS the recovered prompt. No
model call — this is exact data lifted from the image."""
now_ms = int(time.time() * 1000)
pid = f"meta_{uuid.uuid4().hex[:12]}"
# auto / auto-run apply immediately (frontend honors status=="applied");
# ask waits for the user's Accept.
status = "applied" if mode in ("auto", "auto-run") else "pending"
proposal = {
"tool_input": {"request": "recover prompt from image metadata"},
"tool_result": {"output_text": doc, "sections": _doc_to_sections(doc),
"prompt_state": None},
"status": status,
"createdAt": now_ms,
}
text = ("This image was generated with PromptChain — here's its prompt, "
"ready to load.")
turn = {"role": "assistant", "text": text, "proposalId": pid,
"timestamp": now_ms}
history.append({"role": "assistant", "content": [{"type": "text", "text": text}]})
_emit_agent(request_id, "agent_done")
return {
"request_id": request_id,
"new_turns": [turn],
"new_proposals": {pid: proposal},
"history_for_persistence": history,
"history_summary": history_summary,
}
# ── image aspect-extraction rails ──────────────────────────────────
# The patch/rails pipeline that builds the prompt is TEXT-ONLY — it never
# sees the image. So when the user says "use this pose / outfit", the chat
# agent can't just write "this pose" deictically (the downstream pipeline
# has nothing to resolve). We extract concrete text from the image FIRST
# via decomposed per-region vision sub-calls — focused questions beat one
# omnibus prompt (an 8B glosses/hallucinates on "describe the whole pose").
# Validated: 8B+rail ≈ 30B quality, far better than 8B single-shot.
_ASPECT_SYS = (
"/no_think\nYou are a precise visual describer. Answer ONLY what is "
"asked, in plain words, no preamble, no markdown."
)
# Pose = pure body posture: NO camera/framing and NO furniture/surface
# (see feedback_poses_pure_body_posture — users pick framing per-render).
_POSE_RAIL = [
("posture", "In one short clause, the subject's overall body posture "
"(sitting / standing / kneeling / lying / crouching). Do NOT mention "
"furniture, the floor, or what they rest on."),
("legs", "Describe ONLY the legs and feet position in one short clause "
"(bent / extended / raised / crossed; are the soles toward the viewer?). "
"Ignore everything else."),
("arms", "Describe ONLY the arms and hands position in one short clause "
"(resting where, holding what, raised / lowered). Ignore everything else."),
("torso_head", "Describe ONLY the torso lean and the head tilt and gaze "
"direction in one short clause (e.g. 'leaning back, head tilted down, "
"looking at the viewer'). Do NOT mention eye colour, face, or hair."),
]
_OUTFIT_RAIL = [
("garment", "Describe ONLY the clothing the subject wears: garment type, "
"cut, neckline, straps, length, any slits, material, and colour, in one "
"sentence. Ignore worn accessories, pose, body, and background."),
("footwear", "Describe ONLY the footwear (shoes, boots, sandals, socks, "
"stockings). If the feet are bare with NO footwear, reply 'barefoot'. "
"One short phrase."),
("accessories", "Name the specific worn accessories you can see (for "
"example: eyepatch, gold earrings, hair ornament, pink nail polish). "
"Output the actual items, NOT category words like 'jewelry' or "
"'eyewear'. Do not mention footwear here. Reply 'none' if there are none."),
]
_STYLE_RAIL = [
("medium", "In one short phrase, the overall art / rendering style and "
"medium (e.g. anime, photorealistic, 3D render, oil painting, "
"cel-shaded, watercolour). Ignore the subject, clothing, and pose."),
("lighting", "In one short phrase, the lighting and render quality (e.g. "
"cinematic lighting, soft studio light, high detail, depth of field, "
"film grain). Ignore the subject, clothing, and pose."),
]
_POSE_REF_RE = re.compile(
r"\b(pose|posture|posing|positioned|stance)\b", re.IGNORECASE)
_OUTFIT_REF_RE = re.compile(
r"\b(outfit|clothes|clothing|wearing|attire|costume|wardrobe)\b",
re.IGNORECASE)
_STYLE_REF_RE = re.compile(
r"\b(style|aesthetic|rendering|cel.?shad\w*|painterly|art.?style)\b",
re.IGNORECASE)
def _detect_reference_aspects(text: str) -> dict:
"""Which image aspects the user asked to borrow ('use this pose', 'and the
outfit', 'this style'). Empty dict → no specific aspect (full recreate)."""
text = text or ""
return {
"pose": bool(_POSE_REF_RE.search(text)),
"outfit": bool(_OUTFIT_REF_RE.search(text)),
"style": bool(_STYLE_REF_RE.search(text)),
}
async def _run_aspect_rail(request_id: str, image_datas: list[dict],
config: dict, provider: str,
rail: list[tuple], sub: str) -> str:
"""Run a decomposed extraction rail: one focused vision call per facet,
assembled comma-separated. Empty/'none' facets are dropped."""
pieces: list[str] = []
for key, question in rail:
try:
ans = await ai_api._call_provider_complete(
f"{request_id}-{sub}-{key}", provider, config,
_ASPECT_SYS, question, image_datas,
)
except Exception:
logger.warning("agent[%s] aspect-rail %s/%s failed",
request_id, sub, key, exc_info=True)
ans = ""
ans = " ".join((ans or "").split()).strip().rstrip(".")
if ans and ans.lower() != "none":
pieces.append(ans)
return ", ".join(pieces)
# Text-extraction over a ComfyUI/PromptChain render's EMBEDDED prompt. This
# is ground truth (the authored pose/outfit, not a pixel re-interpretation),
# it's faster than vision, and the exclusion clause is how we avoid pulling
# the reference image's character onto the user's subject ("Cammy, not Juri").
_TEXT_ASPECT_SYS = (
"/no_think\nYou extract one aspect from an image-generation prompt. "
"Return only a short comma-separated phrase, no preamble, no markdown."
)
_TEXT_POSE_INSTR = (
"From the prompt below, extract ONLY the body pose and limb/feet "
"position. EXCLUDE the character name, identity, body type, hair, eyes, "
"face, art/render style, and camera framing. Reply 'none' if there is no "
"pose."
)
_TEXT_OUTFIT_INSTR = (
"From the prompt below, extract ONLY the clothing/outfit and footwear "
"(say 'barefoot' if the feet are bare). EXCLUDE the character name, "
"identity, body type, hair, eyes, face, and art/render style. Reply "
"'none' if there is no clothing."
)
_TEXT_STYLE_INSTR = (
"From the prompt below, extract ONLY the art / rendering style, medium, "
"lighting, and render-quality phrases (e.g. 'hyperrealistic anime, "
"cinematic lighting, sharp focus'). EXCLUDE the character name, identity, "
"body, clothing, and pose. Reply 'none' if there is no style."
)
async def _text_extract_aspect(request_id: str, doc: str, sub: str,
instruction: str, config: dict,
provider: str) -> str:
try:
ans = await ai_api._call_provider_complete(
f"{request_id}-meta-{sub}", provider, config,
_TEXT_ASPECT_SYS, f"{instruction}\n\nPROMPT: {doc}", [],
)
except Exception:
logger.warning("agent[%s] metadata aspect %s failed",
request_id, sub, exc_info=True)
return ""
ans = " ".join((ans or "").split()).strip().rstrip(".")
return "" if ans.lower() == "none" else ans
async def _extract_reference_aspects(
request_id: str, image_hashes: list[str], aspects: dict,
config: dict, provider: str,
) -> dict:
"""Extract the requested pose/outfit aspects from the first uploaded image.
Prefers TEXT extraction over a ComfyUI render's embedded prompt (ground
truth, character excluded); falls back to the decomposed VISION rails for
external images with no metadata. Returns {pose?, outfit?}."""
out: dict = {}
doc = _extract_doc_from_uploads(image_hashes)
if doc:
if aspects.get("pose"):
txt = await _text_extract_aspect(request_id, doc, "pose",
_TEXT_POSE_INSTR, config, provider)
if txt:
out["pose"] = txt
if aspects.get("outfit"):
txt = await _text_extract_aspect(request_id, doc, "outfit",
_TEXT_OUTFIT_INSTR, config, provider)
if txt:
out["outfit"] = txt
if aspects.get("style"):
txt = await _text_extract_aspect(request_id, doc, "style",
_TEXT_STYLE_INSTR, config, provider)
if txt:
out["style"] = txt
if out:
logger.info("agent[%s] extracted aspects from image METADATA "
"(no vision)", request_id)
return out
# No metadata (external image) — fall back to decomposed vision rails.
from . import chat_uploads
image_datas = [
d for h in image_hashes if (d := chat_uploads.load_image_data(h))
]
if not image_datas:
return {}
if aspects.get("pose"):
txt = await _run_aspect_rail(request_id, image_datas, config, provider,
_POSE_RAIL, "pose")
if txt:
out["pose"] = txt
if aspects.get("outfit"):
txt = await _run_aspect_rail(request_id, image_datas, config, provider,
_OUTFIT_RAIL, "outfit")
if txt:
out["outfit"] = txt
if aspects.get("style"):
txt = await _run_aspect_rail(request_id, image_datas, config, provider,
_STYLE_RAIL, "style")
if txt:
out["style"] = txt
if out:
logger.info("agent[%s] extracted aspects via VISION rails", request_id)
return out
def _compose_reference_instruction(original: str, extracted: dict) -> str:
"""Fold concrete extracted aspects back into the user's instruction so the
text-only patch pipeline gets real descriptions instead of deixis ('this
pose'). Keeps the original (which names the subject) and appends the
resolved outfit/pose."""
parts = [original.strip().rstrip(".")]
if extracted.get("outfit"):
parts.append(f"Use this exact outfit from the reference image: "
f"{extracted['outfit']}")
if extracted.get("pose"):
parts.append(f"Use this exact body pose from the reference image: "
f"{extracted['pose']}")
if extracted.get("style"):
parts.append(f"Use this exact art style from the reference image: "
f"{extracted['style']}")
return ". ".join(parts) + "."
async def _resolve_provider(config: dict) -> dict:
"""Decide which streaming path to use based on the user's AI config.
Mirrors the dispatch in ai_api._run_generation. Returns:
{kind: "claude"|"openai", api_key, model, base_url?, is_ollama?}
or raises RuntimeError with a user-facing message."""
provider = (config.get("provider") or "").strip()
if provider == "cloud":
cloud = config.get("cloud") or {}
service = (cloud.get("service") or "claude").strip()
api_key = (cloud.get("api_key") or "").strip()
model = (cloud.get("model") or "").strip()
if not api_key:
raise RuntimeError("Cloud API key not configured.")
if not model:
raise RuntimeError("Cloud model not configured.")
if service == "claude":
return {"kind": "claude", "api_key": api_key, "model": model}
base_url = ai_api._cloud_base_url(cloud)
if not base_url:
raise RuntimeError("Cloud base URL missing.")
return {
"kind": "openai", "api_key": api_key, "model": model,
"base_url": base_url, "is_ollama": False,
}
if provider == "local":
local = config.get("local") or {}
base_url = (local.get("base_url") or "").strip().rstrip("/")
model = (local.get("model") or "").strip()
if not base_url:
raise RuntimeError("Local base URL not configured.")
if not model:
raise RuntimeError("Local model not configured.")
ollama_root = ai_api._ollama_root(base_url)
is_ollama = await ai_api._is_ollama(ollama_root)
return {
"kind": "openai", "api_key": None, "model": model,
"base_url": base_url, "is_ollama": is_ollama,
}
raise RuntimeError(f"Unknown provider {provider!r}. Configure cloud or local in AI settings.")
async def _stream_turn(request_id: str, prov: dict,
system_prompt: str, history: list[dict],
*, tools: list[dict] | None = None) -> dict:
"""Single turn against the configured provider. `tools=None` uses the
default `_AGENT_TOOLS` surface; pass `tools=[]` for a narration-only
call (assistant pass — no tool selection)."""
if prov["kind"] == "claude":
return await _stream_claude_with_tools(
request_id, prov["api_key"], prov["model"], system_prompt, history,
tools=tools,
)
return await _stream_openai_compat_with_tools(
request_id, prov["base_url"], prov["model"], system_prompt, history,
api_key=prov.get("api_key"), is_ollama=bool(prov.get("is_ollama")),
tools=tools,
)
async def _run_agent_loop(request: web.Request, body: dict) -> dict:
request_id = body.get("request_id") or uuid.uuid4().hex
mode = (body.get("mode") or "ask").strip()
node_ctx = body.get("node_ctx") or {}
history: list[dict] = list(body.get("history") or [])
history_summary: str = (body.get("history_summary") or "").strip()
# Per-stage timing dict — emitted as a one-line summary at the end so
# we can see where time goes without parsing the whole log. Pattern
# mirrors ai_api._api_patch's `_timing` collector.
timing: dict[str, float] = {}
t_total_start = time.perf_counter()
config = ai_api._load_config()
try:
prov = await _resolve_provider(config)
except RuntimeError as e:
return {"error": str(e)}
timing["setup"] = time.perf_counter() - t_total_start
# Bound the model's context: drop the oldest turns past the budget and
# fold them into history_summary (injected into the system prompt below).
# No-op for short conversations.
_pre_len = len(history)
history, history_summary = _compact_history(history, history_summary, prov)
if len(history) != _pre_len:
logger.info(
"agent[%s] compacted history: %d→%d msgs, summary=%dch",
request_id, _pre_len, len(history), len(history_summary),
)
# Recent text from the last few history messages (user + assistant)
# for bios preflight n-gram extraction. Used in every patch dispatch
# so character mentions land bios regardless of WHICH turn introduced
# them — important for lookup-then-confirm flows where turn 1's
# assistant reply identifies the character and turn 2's user message
# is just 'yes'.
latest_user_text = _extract_recent_text(history, max_messages=6)
current_user_text = _extract_current_user_text(history)
# ── Uploaded images (current turn) ──────────────────────────────
# The frontend sends just-uploaded image(s) as {hash} refs in `images`;
# pixels live on disk under the user folder, never in the workflow JSON.
# We rehydrate them into the LAST user message for THIS model call only
# (caption-once: older turns never re-send bytes, and
# history_for_persistence is stripped back to text below).
image_hashes = [
im["hash"] for im in (body.get("images") or [])
if isinstance(im, dict) and im.get("hash")
]
injected_image_msg = None
has_image = False
if image_hashes:
# Metadata pre-pass: a ComfyUI/PromptChain render embeds its own
# prompt — recover it exactly (no vision call) and offer it as a
# proposal. Only when the user added no text of their own; if they
# asked something ("who is this?"), fall through to the vision path.
if not current_user_text.strip():
meta_doc = _extract_doc_from_uploads(image_hashes)
if meta_doc:
logger.info("agent[%s] recovered prompt from image metadata "
"(%d chars), skipping vision", request_id, len(meta_doc))
return _metadata_prompt_response(
request_id, history, history_summary, meta_doc, mode,
)
# Aspect-reference ("use this pose / outfit"): the patch pipeline is
# text-only, so extract concrete pose/outfit text from the image NOW
# via the decomposed rails, fold it into the instruction, and consume
# the image — the agent then runs a normal text edit with real
# descriptions instead of deferring with "this pose".
image_consumed = False
aspects = _detect_reference_aspects(current_user_text)
if current_user_text.strip() and (aspects["pose"] or aspects["outfit"]):
# Carry the reference's style by default on any image-reference
# build. Without a // Style section the rails auto-seed the
# model's default (often photorealistic), which clashes with an
# anime reference. The reference's own style is the coherent
# default; the user can still override ('...but photorealistic').
aspects["style"] = True
extracted = await _extract_reference_aspects(
request_id, image_hashes, aspects,
config, config.get("provider") or "",
)
if extracted:
rewritten = _compose_reference_instruction(current_user_text, extracted)
_set_last_user_text(history, rewritten)
current_user_text = rewritten
image_consumed = True
logger.info("agent[%s] extracted image aspects %s; rewrote "
"instruction to %r", request_id,
list(extracted.keys()), rewritten[:200])
if not image_consumed:
from . import chat_uploads
image_blocks = [
blk for h in image_hashes
if (blk := chat_uploads.load_image_block(h))
]
if image_blocks:
# Image with no instruction = "make me a prompt for this". An
# 8B left alone narrates instead of calling the tool, so make
# the reverse-prompt intent explicit.
if not current_user_text.strip():
_set_last_user_text(history, _IMAGE_REVERSE_PROMPT_INSTRUCTION)
current_user_text = _IMAGE_REVERSE_PROMPT_INSTRUCTION
injected_image_msg = _attach_images_to_last_user(history, image_blocks)
has_image = injected_image_msg is not None
logger.info("agent[%s] attached %d image(s) for vision",
request_id, len(image_blocks))
# Log the user's most recent literal message so debugging "why
# didn't the agent dispatch?" doesn't require guessing what they
# typed. Truncated for log volume.
_user_only_text = _extract_recent_text(history, max_messages=1)
if _user_only_text:
logger.info(
"agent[%s] user_text=%r",
request_id, _user_only_text[:200],
)
# Knowledge preload: when the latest user message LOOKS LIKE A
# QUESTION (ends with `?` or starts with `what / who / how / which
# / tell me about / describe`), run a strict-filtered match-
# characters preflight and surface any matched bios in the system
# prompt as authoritative character knowledge. Solves the failure
# mode where 8B answers "what color hair does cammy have?" from
# world knowledge and gets it wrong.
#
# GATED on question-shape (not always-on): the bio block adds
# ~200-500 tokens per matched bio to the system prompt. Always-on
# caused multi-character scenarios (cammy + chun-li borrow) to
# regress because both bios got surfaced and shifted the agent's
# paraphrasing behavior. For tool calls, the agent's
# `character_queries` field still drives bio resolution
# downstream — this block only feeds the text-response path,
# which is exactly the path question-shape detection identifies.
#
# The strict filter (`_filter_bios_for_agent_preload`) drops
# single-word prefix matches (`night→night_angel` etc.) that
# polluted the pre-T4 raw-text preflight.
preload_bios: list[dict] = []
if _user_only_text and _looks_like_question(_user_only_text):
try:
raw_bios = await _preflight_bios(
request, _user_only_text, "",
)
preload_bios = _filter_bios_for_agent_preload(
raw_bios, _user_only_text,
)
if preload_bios:
logger.info(
"agent[%s] knowledge preload: bios=%d after filter "
"(raw=%d): %s",
request_id, len(preload_bios), len(raw_bios),
", ".join((b.get("tag") or "?") for b in preload_bios),
)
except Exception:
logger.warning("agent[%s] knowledge preload failed",
request_id, exc_info=True)
new_proposals: dict[str, dict] = {}
new_assistant_blocks: list[dict] = []
# Single-pass agent loop (Copilot / Continue.dev pattern). One system
# prompt covers routing + narration. Tools attached every call. Model
# decides tool-vs-text on the fly; we loop until it stops emitting
# tool_use blocks. The A/B harness (_harness/chat_e2e_probe.py) showed this
# pattern is ~16% faster than the prior two-pass split with identical
# pass rate across 8 fixtures — primarily because Ollama can reuse
# the KV cache across calls within a turn (same system prompt both
# times). The two-pass _router_system_prompt / _assistant_system_prompt
# helpers are kept for that harness only.
combined_prompt = _combined_system_prompt(
node_ctx, preload_bios, history_summary, has_image=has_image,
)
for hop in range(_AGENT_MAX_HOPS):
t_hop = time.perf_counter()
result = await _stream_turn(
request_id, prov, combined_prompt, history,
)
timing[f"hop{hop+1}_model"] = time.perf_counter() - t_hop
tool_blocks = [
b for b in result["content"] if b.get("type") == "tool_use"
]
# One-line decision telemetry per hop.
if tool_blocks:
for b in tool_blocks:
inp = b.get("input") or {}
arg_preview = (
(inp.get("request") or inp.get("filter") or "")[:80]
)
logger.info(
"agent_hop[%s] hop=%d tool=%s args=%r",
request_id, hop + 1, b.get("name"), arg_preview,
)
else:
text_preview = "".join(
b.get("text", "") for b in result["content"]
if b.get("type") == "text"
)[:80]
logger.info(
"agent_hop[%s] hop=%d text=%r",
request_id, hop + 1, text_preview,
)
# Persist this hop's assistant turn in history regardless.
if result["content"]:
history.append({"role": "assistant", "content": result["content"]})
new_assistant_blocks.extend(result["content"])
if not tool_blocks:
# Terminal hop: model emitted text only. That's the reply.
break
# Dispatch tool calls and build Anthropic-canonical tool_result
# blocks. `_to_openai_messages` translates these to OpenAI
# role:tool / tool_call_id shape at the wire boundary.
tool_result_blocks: list[dict] = []
for block in tool_blocks:
proposal_id = block.get("id") or f"toolu_{uuid.uuid4().hex[:12]}"
tool_name = block.get("name")
tool_input = block.get("input") or {}
if tool_name == "list_model_styles":
try:
res = _dispatch_list_model_styles(tool_input, node_ctx)
logger.info(
"agent[%s] list_model_styles arch=%r filter=%r "
"returned=%d",
request_id, res["arch"],
res["filter"] or None, res["count"],
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": json.dumps(res["data"], indent=2),
})
_emit_agent(
request_id, "agent_tool_result",
proposal_id=proposal_id,
tool_name="list_model_styles",
count=res["count"],
)
except Exception as e:
logger.exception(
"agent[%s] list_model_styles failed", request_id,
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": f"list_model_styles failed: {e}",
"is_error": True,
})
continue
if tool_name == "populate_inline_wildcards":
now_ms = int(time.time() * 1000)
try:
res = _dispatch_populate_inline_wildcards(
tool_input, node_ctx,
)
logger.info(
"agent[%s] populate_inline_wildcards source=%r "
"added=%d skipped=%d total=%d",
request_id, res.get("source"),
len(res.get("added") or []),
len(res.get("skipped") or []),
res.get("total_entries", 0),
)
# Carry the new node content as output_text so the
# frontend applies it the same way it applies an
# apply_prompt_patch proposal. No diff sections — the
# change is an append, summarized in `summary`.
proposal = {
"tool_input": tool_input,
"tool_result": {
"output_text": res["content"],
"sections": [],
"pipeline": "inline-wildcard-populate",
"added": res["added"],
"skipped": res["skipped"],
"summary": res["summary"],
},
"status": "pending" if mode == "ask" else "applied",
"createdAt": now_ms,
}
if proposal["status"] == "applied":
proposal["appliedAt"] = now_ms
new_proposals[proposal_id] = proposal
_emit_agent(
request_id, "agent_tool_result",
proposal_id=proposal_id,
added=len(res.get("added") or []),
applied=(proposal["status"] == "applied"),
)
# Only the compact summary goes back to the agent —
# the full content (could be dozens of entries) stays
# in the proposal, keeping narration context small.
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": res["summary"],
})
except Exception as e:
logger.exception(
"agent[%s] populate_inline_wildcards failed",
request_id,
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": f"populate_inline_wildcards failed: {e}",
"is_error": True,
})
continue
if tool_name == "generate_subjects":
now_ms = int(time.time() * 1000)
try:
res = _dispatch_generate_subjects(tool_input, node_ctx)
logger.info(
"agent[%s] generate_subjects mode=%r count=%d seed=%s",
request_id, res.get("mode"),
len(res.get("added") or []), res.get("seed"),
)
proposal = {
"tool_input": tool_input,
"tool_result": {
"output_text": res["content"],
"sections": [],
"pipeline": "generate-subjects",
"mode": res.get("mode"),
"added": res.get("added"),
"seed": res.get("seed"),
"summary": res["summary"],
},
"status": "pending" if mode == "ask" else "applied",
"createdAt": now_ms,
}
if proposal["status"] == "applied":
proposal["appliedAt"] = now_ms
new_proposals[proposal_id] = proposal
_emit_agent(
request_id, "agent_tool_result",
proposal_id=proposal_id,
added=len(res.get("added") or []),
applied=(proposal["status"] == "applied"),
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": res["summary"],
})
except Exception as e:
logger.exception(
"agent[%s] generate_subjects failed", request_id,
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": f"generate_subjects failed: {e}",
"is_error": True,
})
continue
if tool_name == "list_inline_wildcards":
now_ms = int(time.time() * 1000)
try:
res = _dispatch_list_inline_wildcards(tool_input, node_ctx)
logger.info(
"agent[%s] list_inline_wildcards what=%r -> %s/%s "
"added=%d",
request_id, tool_input.get("what"),
res.get("domain"), res.get("group"),
len(res.get("added") or []),
)
if res.get("added"):
proposal = {
"tool_input": tool_input,
"tool_result": {
"output_text": res["content"],
"sections": [],
"pipeline": "kb-list",
"domain": res.get("domain"),
"group": res.get("group"),
"added": res["added"],
"summary": res["summary"],
},
"status": "pending" if mode == "ask" else "applied",
"createdAt": now_ms,
}
if proposal["status"] == "applied":
proposal["appliedAt"] = now_ms
new_proposals[proposal_id] = proposal
_emit_agent(
request_id, "agent_tool_result",
proposal_id=proposal_id,
added=len(res["added"]),
applied=(proposal["status"] == "applied"),
)
# No-match (or all-present): no proposal, just the note
# back so the agent can tell the user.
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": res["summary"],
})
except Exception as e:
logger.exception(
"agent[%s] list_inline_wildcards failed", request_id,
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": f"list_inline_wildcards failed: {e}",
"is_error": True,
})
continue
if tool_name != "apply_prompt_patch":
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": f"Unknown tool '{tool_name}' — not invoked.",
"is_error": True,
})
continue
patch_request = (tool_input.get("request") or "").strip()
if not patch_request:
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": "apply_prompt_patch rejected: empty `request` field.",
"is_error": True,
})
continue
now_ms = int(time.time() * 1000)
try:
t_patch = time.perf_counter()
raw_cq = tool_input.get("character_queries")
if isinstance(raw_cq, str):
raw_cq = [raw_cq]
if not isinstance(raw_cq, list):
raw_cq = None
patch_resp = await _call_patch_internal(
request, node_ctx, patch_request, request_id,
latest_user_text=latest_user_text,
current_user_text=current_user_text,
character_queries=raw_cq,
)
timing[f"hop{hop+1}_patch"] = time.perf_counter() - t_patch
patch_summary = _format_tool_result_summary(patch_resp)
proposal = {
"tool_input": tool_input,
"tool_result": patch_resp,
"status": "pending" if mode == "ask" else "applied",
"createdAt": now_ms,
}
if proposal["status"] == "applied":
proposal["appliedAt"] = now_ms
new_proposals[proposal_id] = proposal
_emit_agent(
request_id, "agent_tool_result",
proposal_id=proposal_id,
sections_count=len(patch_resp.get("sections") or []),
applied=(proposal["status"] == "applied"),
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": "apply_prompt_patch result:\n" + patch_summary,
})
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception(
"agent[%s] patch dispatch failed", request_id,
)
new_proposals[proposal_id] = {
"tool_input": tool_input,
"tool_result": {"error": str(e)},
"status": "failed",
"createdAt": now_ms,
}
_emit_agent(
request_id, "agent_tool_result",
proposal_id=proposal_id, applied=False, error=str(e),
)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": proposal_id,
"content": f"apply_prompt_patch failed: {e}",
"is_error": True,
})
# Append the tool_results as a user-turn block. Next hop's call
# sees the full canonical exchange in history; the model loops
# back to narrate or call another tool.
history.append({"role": "user", "content": tool_result_blocks})
else:
# for-else: hop limit hit without a final text turn.
logger.warning(
"agent[%s] hit max_hops=%d without final text reply",
request_id, _AGENT_MAX_HOPS,
)
_emit_agent(
request_id, "agent_text",
content="(stopped: tool-call hop limit reached)", partial=False,
)
new_assistant_blocks.append({
"type": "text",
"text": "(stopped: tool-call hop limit reached)",
})
_emit_agent(request_id, "agent_done")
# One-line timing summary so we can see where each turn's wall-clock
# went without parsing the full log. router_model / assistant_model =
# chat-agent inference; patch = nested /ai/patch round-trip.
timing["total"] = time.perf_counter() - t_total_start
model_total = sum(v for k, v in timing.items() if k.endswith("_model"))
patch_total = timing.get("patch", 0.0)
summary = " ".join(f"{k}={v:.2f}s" for k, v in timing.items())
logger.info(
"agent[%s] proposals=%d agent_model=%.2fs patch=%.2fs total=%.2fs | %s",
request_id, len(new_proposals), model_total, patch_total,
timing["total"], summary,
)
# Strip the rehydrated image blocks back out so the persisted history
# stays text-only (caption-once): the pixels were for this turn's model
# call, not for re-sending on every future turn.
if injected_image_msg is not None:
injected_image_msg["content"] = [
b for b in injected_image_msg["content"] if b.get("type") != "image"
]
new_turns = _project_blocks_to_turns(new_assistant_blocks, new_proposals)
return {
"request_id": request_id,
"new_turns": new_turns,
"new_proposals": new_proposals,
"history_for_persistence": history,
"history_summary": history_summary,
}
def _project_blocks_to_turns(blocks: list[dict], proposals: dict) -> list[dict]:
"""Project the canonical assistant content blocks into UI turn rows.
A text block becomes a turn with `text`. A tool_use block becomes a
turn with `proposalId` (text empty unless paired with the most recent
text block, in which case the text is on the prior turn already).
Read-only tools (list_model_styles etc.) don't add an entry to
`proposals` — their result is folded into the next narration turn.
Emitting a tool_use turn for them would render as an empty 'AI'
bubble in the chat, so skip the turn entirely. Any pending text
is preserved by flushing it into the standalone text turn that
follows the tool dispatch."""
turns: list[dict] = []
pending_text = ""
for block in blocks:
if block.get("type") == "text":
t = block.get("text") or ""
if t.strip():
if pending_text:
turns.append({
"role": "assistant",
"text": pending_text,
"timestamp": int(time.time() * 1000),
})
pending_text = t
elif block.get("type") == "tool_use":
proposal_id = block.get("id")
# Read-only tools have no proposal — skip the row to avoid
# an empty 'AI' label. Pending text (if any) carries forward
# to the next text turn.
if proposal_id not in proposals:
continue
turns.append({
"role": "assistant",
"text": pending_text,
"proposalId": proposal_id,
"timestamp": int(time.time() * 1000),
})
pending_text = ""
if pending_text:
turns.append({
"role": "assistant",
"text": pending_text,
"timestamp": int(time.time() * 1000),
})
return turns
# ── route ─────────────────────────────────────────────────────────────
@routes.post("/promptchain/ai/upload-image")
async def _api_upload_image(request):
"""Persist a chat-uploaded image to the user folder; return its hash +
serve URL + dimensions. The frontend keeps only the hash/URL in chat
state — pixels never enter the workflow JSON."""
body, err = await parse_json(request)
if err:
return err
from . import chat_uploads
meta = chat_uploads.save_upload(body.get("data") or "", body.get("media_type"))
if not meta:
return error_response("invalid or unsupported image", 400)
width = height = None
try:
from PIL import Image
with Image.open(chat_uploads.resolve_upload_path(meta["hash"])) as im:
width, height = im.size
except Exception:
logger.debug("upload dimension probe failed", exc_info=True)
return web.json_response({
"hash": meta["hash"],
"url": f"/promptchain/ai/upload/{meta['hash']}",
"media_type": meta["media_type"],
"width": width,
"height": height,
})
@routes.get("/promptchain/ai/upload/{hash}")
async def _api_serve_upload(request):
image_hash = request.match_info.get("hash", "")
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
from . import chat_uploads
path = chat_uploads.resolve_upload_path(image_hash)
if not path:
return error_response("not found", 404)
import mimetypes as mt
mime, _ = mt.guess_type(str(path))
return web.FileResponse(path, headers={
"Content-Type": mime or "application/octet-stream",
"Cache-Control": "public, max-age=31536000",
})
@routes.post("/promptchain/ai/chat")
async def _api_chat(request):
body, err = await parse_json(request)
if err:
return err
request_id = (body.get("request_id") or "").strip() or uuid.uuid4().hex
body["request_id"] = request_id
task = asyncio.create_task(_run_agent_loop(request, body))
ai_api._active_requests[request_id] = task
try:
result = await task
except asyncio.CancelledError:
ai_api._emit(request_id, "cancelled")
# Frontend treats agent_done as the terminal signal; without it
# after a cancel mid-stream the panel shows partial deltas with
# no end marker.
_emit_agent(request_id, "agent_done")
return error_response("cancelled", 499)
except Exception as e:
logger.exception("ai_chat failed")
msg = _friendly_chat_error(e)
ai_api._emit(request_id, "error", error=msg)
_emit_agent(request_id, "agent_done")
return error_response(msg, 500)
finally:
ai_api._cleanup_request(request_id)
if isinstance(result, dict) and result.get("error"):
return error_response(result["error"], 400)
return web.json_response(result)
def _friendly_chat_error(e: Exception) -> str:
"""Rewrite aiohttp connection refusals into something an end-user can
act on. Looks up the configured provider so the message names what's
actually unreachable (Ollama vs cloud API)."""
raw = str(e)
is_conn = isinstance(e, aiohttp.ClientConnectorError) or "Cannot connect to host" in raw
if not is_conn:
return raw
cfg = ai_api._load_config()
provider = cfg.get("provider")
if provider == "local":
base = ((cfg.get("local") or {}).get("base_url") or "").strip().rstrip("/")
target = base or "localhost:11434"
return f"Can't reach Ollama at {target}. Start Ollama and try again."
if provider == "cloud":
service = ((cfg.get("cloud") or {}).get("service") or "claude")
return f"Can't reach the {service} API. Check your internet connection."
return f"Can't reach the AI provider: {raw}"