Files
DemonGatanjieuandClaude Opus 5.5 c3433a9241 Pick a way for a task, and ship official example workflows
Ways: "Use it this time" in the Ways view, for one task. It joins the
task's picks as a new kind, the AI is told, and add_nodes builds that way
for its use whichever one the AI asks for. "Only" lets in the nodes a
picked way builds.

Examples: text to picture, two passes, picture to picture and repainting
an area, on ComfyUI's own nodes. One card, first on the Workflows page,
opens them in a tab of their own. The files name no model or picture; a
checkpoint and a picture that are here are filled in when one is read.
list_workflows gives them to an AI when no saved workflow fits, and they
open and run by name like saved ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:51:26 +08:00

516 lines
28 KiB
Python

"""Panels the AI draws in its task (show_panel), from Anomalous's own blocks, and the answers
the user gives on them.
A panel is a step of the AI's current task (ai_tasks.py) holding a title and a list of blocks:
heading, text, images (from ComfyUI's output folder, with marks: circles or boxes and a label
at places given as fractions of the picture), slider (two pictures over each other, a line to
drag between them), picks (pictures with a reason and "take this one"), models (the user's, by
model_id), diagram (boxes and arrows, laid out by the page; a box can name a canvas node),
table, list, params, choices (buttons; one or several picks) and input (a line of text),
prompts (the user's prompt box: texts and reference pictures the AI gets as tokens, ai_sealed.py),
progress, divider; and area (the user's repaint area beside the AI's, ai_repaint.py). Nothing the AI sends is run or rendered as markup, and no drawing of its own
(SVG) is taken: every block is checked here to be one of these shapes, with plain strings and
numbers of bounded size, pictures only from the output folder and models only from the user's
folders (the parts: ai_panel_parts.py). The page draws them (ui_ai_panel.js).
The user's answers come back through POST /anomalous/ai_tasks/{task}/answer and wait in the
step until the AI asks for them (get_answers, which may wait a while for one) or makes its next
successful MCP tool call (attach_pending). The user can also write to the AI in a task
(POST /anomalous/ai_tasks/{task}/message): the message is a step of its own and reaches the AI
the same way.
"""
import re
import threading
import time
import uuid
from . import ai_marks, ai_tasks
from .ai_panel_parts import ( # noqa: F401 - PanelError and the limits are read as ai_panels.*
MAX_ITEMS, MAX_NODES, PanelError, _diagram, _image, _list, _model, _text, _zoom,
)
from .mcp_tools import tool_text
MAX_BLOCKS = 30
MAX_ROWS = 30
MAX_COLUMNS = 8
MAX_WAIT = 55
TONES = ("normal", "muted", "tip", "warning")
LAYOUTS = ("row", "grid", "compare")
INTERACTIVE = ("choices", "input", "picks", "prompts")
MAX_PROMPT_BOXES = 8
INPAINT_NOTE = (
"inpaint_image is the picture with the area to repaint as its alpha, already in ComfyUI's input folder: do not "
"check it with a command (get_image_info(image=inpaint_image, folder='input', include_image=true) shows it). "
"Repaint with a workflow, never by painting the file with code. On a workflow that makes this picture (its model "
"and prompt: get_image_info on the picture says them), add_nodes(use=\"repaint\", image=inpaint_image) "
"builds the repaint in one call, wired into its sampler (the best repaint this ComfyUI can run); set_prompt "
"the sampler's prompt to what belongs in the area (\"a hand, five fingers\"), not the whole picture. Keep the block's low denoise (it keeps the thing's size; higher paints a new one filling the whole "
"area). run_workflow with batch 4 (each try gets its own seed) and let the user pick in a picks panel zoomed on the "
"area (zoom {x, y, w, h} around it), then show the old and new with a slider panel, zoomed the same. Say in "
"report what you set and why."
)
_answered = threading.Condition(ai_tasks._lock)
def clean_block(block, at, taken):
"""One block as the page may draw it, or PanelError."""
where = f"blocks[{at}]"
if not isinstance(block, dict) or not isinstance(block.get("type"), str):
raise PanelError(f"{where} must be an object with a type")
kind = block["type"]
out = {"type": kind}
if kind == "heading":
out["text"] = _text(block.get("text"), 120, f"{where}.text", True)
elif kind == "text":
out["text"] = _text(block.get("text"), 4000, f"{where}.text", True)
out["tone"] = block.get("tone") if block.get("tone") in TONES else "normal"
elif kind == "images":
items = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if not items:
raise PanelError(f"{where}.items is empty")
out["items"] = [_image(item, f"{where}.items[{i}]") for i, item in enumerate(items)]
out["layout"] = block.get("layout") if block.get("layout") in LAYOUTS else "row"
elif kind == "slider":
out.update(before=_image(block.get("before"), f"{where}.before"), after=_image(block.get("after"), f"{where}.after"),
before_label=_text(block.get("before_label"), 40, f"{where}.before_label"),
after_label=_text(block.get("after_label"), 40, f"{where}.after_label"))
if block.get("zoom"):
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
elif kind == "picks":
items, ids = [], set()
raw = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if not raw: # one is "take this one" (a single base, ai_bases.py)
raise PanelError(f"{where}.items needs 1 to {MAX_ITEMS} pictures")
for i, item in enumerate(raw):
here = f"{where}.items[{i}]"
if not isinstance(item, dict):
raise PanelError(f"{here} must be {{id, image, label, reason, recommended}}")
item_id = _text(item.get("id") or f"p{i + 1}", 40, f"{here}.id")
if item_id in ids:
raise PanelError(f"{here}.id {item_id!r} twice")
ids.add(item_id)
picture = _image(item, here)
items.append({**picture, "id": item_id, "label": _text(item.get("label"), 60, f"{here}.label"),
"reason": _text(item.get("reason"), 200, f"{here}.reason"), "recommended": item.get("recommended") is True})
out.update(question=_text(block.get("question"), 200, f"{where}.question"), items=items,
multiple=block.get("multiple") is True)
if _page_word(block.get("question_key")):
out["question_key"] = block["question_key"]
if block.get("zoom"):
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
elif kind == "run_map": # a run's real shape (ai_run_map.py), drawn by the page from the step; only show_run_map makes it
out["step"] = _text(block.get("step"), 20, f"{where}.step", True)
elif kind == "area": # the user's repaint area and the AI's, pictures in the task's marks folder; only ai_repaint makes it
if not all(ai_marks.FILE_NAME.fullmatch(str(block.get(side))) for side in ("yours", "ai")):
raise PanelError(f"{where}: an area block shows pictures Anomalous made")
out.update({side: block[side] for side in ("yours", "ai")}, grow=int(block.get("grow") or 0), feather=int(block.get("feather") or 0))
elif kind == "diagram":
out.update(_diagram(block, where))
elif kind == "models":
items = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if not items:
raise PanelError(f"{where}.items is empty")
out["items"] = [_model(item, f"{where}.items[{i}]") for i, item in enumerate(items)]
elif kind == "table":
columns = [_text(c, 60, f"{where}.columns") for c in _list(block.get("columns"), MAX_COLUMNS, f"{where}.columns")]
rows = []
for r, row in enumerate(_list(block.get("rows"), MAX_ROWS, f"{where}.rows")):
cells = _list(row, MAX_COLUMNS, f"{where}.rows[{r}]")
rows.append([_text(cell, 200, f"{where}.rows[{r}]") for cell in cells])
if not rows:
raise PanelError(f"{where}.rows is empty")
out.update(columns=columns, rows=rows)
elif kind == "list":
items = [_text(item, 300, f"{where}.items") for item in _list(block.get("items"), MAX_ROWS, f"{where}.items")]
if not items:
raise PanelError(f"{where}.items is empty")
out.update(items=items, ordered=block.get("ordered") is True)
elif kind == "params":
items = []
for i, item in enumerate(_list(block.get("items"), MAX_ROWS, f"{where}.items")):
if not isinstance(item, dict):
raise PanelError(f"{where}.items[{i}] must be {{label, value}}")
items.append({"label": _text(item.get("label"), 60, f"{where}.items[{i}].label", True),
"value": _text(item.get("value"), 300, f"{where}.items[{i}].value")})
if not items:
raise PanelError(f"{where}.items is empty")
out["items"] = items
elif kind == "choices":
options, ids = [], set()
raw = _list(block.get("options"), 8, f"{where}.options")
if len(raw) < 2:
raise PanelError(f"{where}.options needs 2 to 8 options")
for i, option in enumerate(raw):
if not isinstance(option, dict):
option = {"label": option}
label = _text(option.get("label"), 80, f"{where}.options[{i}].label", True)
option_id = _text(option.get("id") or f"o{i + 1}", 40, f"{where}.options[{i}].id")
if option_id in ids:
raise PanelError(f"{where}.options: id {option_id!r} twice")
ids.add(option_id)
options.append({"id": option_id, "label": label, "detail": _text(option.get("detail"), 200, f"{where}.options[{i}].detail"),
**({"recommended": True} if option.get("recommended") is True else {}),
**({key: option[key] for key in ("label_key", "detail_key") if _page_word(option.get(key))}),
**({"values": {str(key): _text(value, 120, f"{where}.options[{i}].values") for key, value in option["values"].items()}}
if isinstance(option.get("values"), dict) and _page_word(option.get("label_key")) else {}),
**({"sample": option["sample"]} if re.fullmatch(r"[a-z_]{1,40}", str(option.get("sample"))) else {})})
# other: a line for an answer none of the options is
out.update(question=_text(block.get("question"), 200, f"{where}.question"), options=options,
multiple=block.get("multiple") is True, other=block.get("other") is True)
elif kind == "input":
out.update(question=_text(block.get("question"), 200, f"{where}.question", True),
placeholder=_text(block.get("placeholder"), 80, f"{where}.placeholder"))
elif kind == "prompts": # filled in Anomalous only: the texts are sealed and the AI gets tokens
count = block.get("count", 1)
if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= MAX_PROMPT_BOXES:
raise PanelError(f"{where}.count: 1 to {MAX_PROMPT_BOXES} prompts")
out.update(question=_text(block.get("question"), 200, f"{where}.question"), count=count,
negative=block.get("negative") is True, pictures=block.get("pictures") is True)
elif kind == "progress":
value, top = block.get("value"), block.get("max", 100)
if not all(isinstance(n, (int, float)) and not isinstance(n, bool) for n in (value, top)) or top <= 0:
raise PanelError(f"{where}: value and max must be numbers, max above 0")
out.update(label=_text(block.get("label"), 120, f"{where}.label"), value=max(0, min(value, top)), max=top)
elif kind == "divider":
pass
else:
raise PanelError(f"{where}.type {kind!r} is not one of heading, text, images, slider, picks, models, diagram, "
"table, list, params, choices, input, prompts, progress, divider")
if kind in INTERACTIVE or block.get("id") is not None:
block_id = _text(block.get("id") or f"q{at + 1}", 40, f"{where}.id")
if block_id in taken:
raise PanelError(f"{where}.id {block_id!r} twice")
taken.add(block_id)
out["id"] = block_id
return out
def clean_blocks(blocks):
blocks = _list(blocks, MAX_BLOCKS, "blocks")
if not blocks:
raise PanelError("blocks is empty")
taken = set()
return [clean_block(block, at, taken) for at, block in enumerate(blocks)]
def _block_ids(blocks):
"""Stable handles for deliberate edits; old records get them only when updated."""
taken = {block["id"] for block in blocks if block.get("id")}
out = []
for block in blocks:
block = dict(block)
if not block.get("id"):
block_id = uuid.uuid4().hex[:10]
while block_id in taken:
block_id = uuid.uuid4().hex[:10]
block["id"] = block_id
taken.add(block_id)
out.append(block)
return out
def _merge_blocks(existing, incoming):
"""Keep the order already seen, append new output, edit only identified blocks in place."""
merged = list(existing)
by_id = {block["id"]: at for at, block in enumerate(existing) if block.get("id")}
used = set()
for raw, block in zip(incoming, clean_blocks(incoming)):
if block["type"] in INTERACTIVE and not raw.get("id"):
# Positional q1/q2 defaults cannot identify a question in a partial append.
block.pop("id")
at = by_id.get(block.get("id"))
if at is None and not block.get("id"):
# A client may send its entire previous layout again. That cannot reorder it.
at = next((i for i, old in enumerate(existing) if i not in used
and {key: value for key, value in old.items() if key != "id"} == block), None)
if at is None and block["type"] == "progress":
matches = [i for i, old in enumerate(existing) if old["type"] == "progress"
and old.get("label") == block.get("label")]
if len(matches) == 1:
at = matches[0]
if at is None:
merged.append(block)
else:
if existing[at]["type"] != block["type"]:
raise PanelError("An existing block id must keep its type; use a new id for new output.")
merged[at] = {**block, **({"id": existing[at]["id"]} if existing[at].get("id") else {})}
used.add(at)
if len(merged) > MAX_BLOCKS:
raise PanelError(f"The panel would exceed {MAX_BLOCKS} blocks; use show_panel for the next stage of work.")
return _block_ids(merged)
def _find(panel_id):
"""(task, step) holding panel `panel_id`, or (None, None). Call under the lock."""
for task in ai_tasks._load().values():
for step in task.get("steps") or []:
if step.get("kind") == "panel" and step.get("panel", {}).get("id") == panel_id:
return task, step
return None, None
def _page_word(key):
"""A word of the page (locales.js) Anomalous's own panels name, shown in the user's language."""
return bool(re.fullmatch(r"ai[A-Z]\w{1,40}", str(key)))
def show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None, title_key=None):
title = _text(title, 120, "title", True)
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False,
**({"title_key": title_key} if _page_word(title_key) else {})}
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": tool, "kind": "panel", "ok": True,
"summary": f"{tool}: {title}", "panel": panel, "answers": {}, "results": [], **(extra or {})}
with ai_tasks._lock:
task = ai_tasks.current_task(client, now, session=session, task_id=task_id, follow_up=True)
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task["id"], {"panel": title, "asks": any(b["type"] in INTERACTIVE for b in panel["blocks"])})
return panel
def update(panel_id, title=None, blocks=None, closed=None):
with ai_tasks._lock:
task, step = _find(str(panel_id))
if not step:
raise PanelError(f"No panel {panel_id!r}; show_panel returns its id.")
# Validate the whole update before replacing the saved panel.
panel = dict(step["panel"])
if title is not None:
panel["title"] = _text(title, 120, "title", True)
if blocks is not None:
panel["blocks"] = _merge_blocks(panel["blocks"], blocks)
panel["changed"] = time.time() # when its numbers were written: the page greys a progress bar the task went past
if closed is not None:
panel["closed"] = bool(closed)
step["panel"] = panel
task["updated"] = max(task.get("updated", 0), time.time())
ai_tasks._save(task)
ai_tasks._notify(task["id"])
return panel
def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=False, extra=None):
"""The user's answer to one block of a panel (from the page), or `skip` it, with `extra` for the AI
(ai_repaint.prepare). Returns the step's answers."""
with _answered:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
if not step or step.get("kind") != "panel":
raise PanelError("No such panel")
if step["panel"].get("closed"):
raise PanelError("The AI closed this panel")
block = next((b for b in step["panel"]["blocks"] if b.get("id") == block_id), None)
if not block or block["type"] not in INTERACTIVE:
raise PanelError("No such question")
if block_id in step.setdefault("answers", {}):
raise PanelError("Already answered")
entry = {"at": time.time() if now is None else now, "delivered": False, **(extra or {})}
if skip:
entry["skipped"] = True
elif block["type"] == "choices" and block.get("other") and not choice and isinstance(text, str) and text.strip():
entry.update(choice=["other"], labels=["other"], text=_text(text, 2000, "text", True))
elif block["type"] == "prompts":
if not (extra or {}).get("prompts"):
raise PanelError("Prompts are written in Anomalous's prompt box")
elif block["type"] in ("choices", "picks"):
picked = choice if isinstance(choice, list) else [choice]
options = block["options"] if block["type"] == "choices" else block["items"]
known = {option["id"]: option.get("label") or option["id"] for option in options}
picked = [str(item) for item in picked if str(item) in known]
if not picked or (len(picked) > 1 and not block.get("multiple")):
raise PanelError("Pick one of the options")
entry.update(choice=picked, labels=[known[item] for item in picked])
if (step.get("routes") or {}).get(picked[0]): # what to call for a route (mcp_routes.py)
entry["note"] = step["routes"][picked[0]]
else:
value = _text(text, 2000, "text", True)
entry["text"] = value
step["answers"][block_id] = entry
task["updated"] = max(task.get("updated", 0), entry["at"])
ai_tasks._save(task)
_answered.notify_all()
answers = dict(step["answers"])
ai_tasks._notify(task_id)
return answers
MAX_MESSAGE = 2000
def leave_message(task_id, text, refs=None, now=None):
"""A message from the user to the AI working on task `task_id`, with what its "@" point at and
the pictures attached (ai_message_refs.py); words may be left out when something is attached.
Returns the step."""
from . import ai_message_refs
try:
refs = ai_message_refs.clean(refs)
except ai_message_refs.RefsError as error:
raise PanelError(str(error))
text = _text(text, MAX_MESSAGE, "text", not refs)
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_message", "kind": "user", "ok": True,
"summary": "user_message", "text": text, "delivered": False, "results": [], **({"refs": refs} if refs else {})}
with _answered:
task = ai_tasks._load().get(task_id)
if not task:
raise PanelError("No such task")
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
task["ended"] = None # writing to the AI goes on with the task: its next call comes back here
ai_tasks._save(task)
_answered.notify_all()
ai_tasks._notify(task_id)
return step
def _collect(client, panel_id, task_id=None):
"""Answers and messages not given to the AI yet (its own tasks, task `task_id`, or panel
`panel_id` and the messages of that panel's task), marked as given. Under the lock."""
out, waiting, messages, changed = [], [], [], set()
tasks = ai_tasks._load()
panel_task = _find(panel_id)[0] if panel_id is not None else None
named_task = tasks.get(task_id) if task_id else None
for task in tasks.values():
if panel_id is not None:
if task is not panel_task:
continue
elif task_id is not None:
if task is not named_task:
continue
elif task.get("client") != client:
continue
for step in task.get("steps") or []:
if step.get("kind") == "user" and not step.get("delivered") and (panel_id is None or task is panel_task):
step.update(delivered=True, delivered_at=time.time())
changed.add(task["id"])
message = {"id": step["id"], "task": task["id"], "text": step["text"], "at": step["at"]}
if step.get("tool") == "user_activity":
message["kind"] = "change" # something the user changed in Anomalous, not words to the AI
elif step.get("tool") == "user_pictures":
from . import ai_pictures
message.update(ai_pictures.message_of(step)) # the pictures themselves go with it
elif step.get("refs"):
from . import ai_message_refs
message.update(ai_message_refs.message_of(step)) # what its "@" point at, pictures attached
elif step.get("tool") == "user_stop":
message["kind"] = "stop" # the user stopped the AI's run (ai_run_control.py)
elif step.get("tool") == "user_toolbox":
message["kind"] = "toolbox" # what the user picked for the AI to work with (ai_toolbox.py)
elif step.get("tool") == "user_marks":
# Marks on a picture: the picture's path for the AI, and the marked one attached (ai_marks.image_items).
message.update(kind="marks", image="/".join(part for part in (step["image"].get("subfolder"), step["image"]["filename"]) if part),
mode=step.get("mode") or ("local" if step.get("inpaint") else "auto"),
_marked=ai_marks.path_of(task["id"], step.get("marked")))
if step.get("inpaint"):
message.update(inpaint_image=step["inpaint"], inpaint_note=INPAINT_NOTE)
messages.append(message)
continue
asked = step.get("gate") or {}
if asked.get("timed_out") and asked.get("decision") and not asked.get("delivered") and panel_id is None:
asked["delivered"] = True
changed.add(task["id"])
messages.append({"id": step["id"], "task": task["id"], "kind": "gate", "at": asked.get("at"),
"text": (f"The user allowed {step['tool']}: call it again with the same arguments now."
if asked["decision"] == "allow" else
f"The user said no to {step['tool']}." + (f" They said: {asked['text']}" if asked.get("text") else ""))})
continue
panel = step.get("panel")
if step.get("kind") != "panel" or (panel_id is not None and panel["id"] != panel_id):
continue
for block in panel["blocks"]:
if block["type"] not in INTERACTIVE:
continue
given = step.get("answers", {}).get(block["id"])
if not given:
if not panel.get("closed"):
waiting.append({"panel": panel["id"], "question": block["id"]})
continue
if given.get("delivered"):
continue
given.update(delivered=True, delivered_at=time.time())
changed.add(task["id"])
item = {"panel": panel["id"], "question": block["id"], "asked": block.get("question", "")}
item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout", "prompts", "negative", "pictures") if key in given})
out.append(item)
for tid in changed:
ai_tasks._save(tasks[tid])
return out, waiting, messages, changed
def wake():
"""Anything waiting in get_answers looks again (the user added something to a task)."""
with _answered:
_answered.notify_all()
def get_answers(client, panel_id=None, wait_seconds=0, task_id=None):
"""The user's new answers and messages; with `wait_seconds`, waits up to that long (MAX_WAIT)
for one: for a panel's question still open, or (no panel named) for anything the user sends."""
deadline = time.monotonic() + max(0, min(float(wait_seconds or 0), MAX_WAIT))
panel_id = str(panel_id) if panel_id else None
notified = set()
try:
with _answered:
if panel_id is not None and _find(panel_id)[1] is None:
raise PanelError(f"No panel {panel_id!r}; show_panel returns its id.")
while True:
answers, waiting, messages, changed = _collect(client, panel_id, task_id=task_id)
notified.update(changed)
left = deadline - time.monotonic()
if answers or messages or left <= 0 or (panel_id is not None and not waiting):
answer = {"answers": answers, "still_waiting": waiting}
if messages:
answer["messages"] = messages
return answer
_answered.wait(left)
finally:
for tid in notified:
ai_tasks._notify(tid)
def attach_pending(result, client, task_id=None):
"""Attaches unread user messages and panel answers to a successful tool result so an AI
that does not call get_answers still receives them once."""
if not isinstance(result, dict) or result.get("isError"):
return result
with _answered:
answers, waiting, messages, changed = _collect(client, None, task_id=task_id)
for tid in changed:
ai_tasks._notify(tid)
if not answers and not messages:
return result
data = result.get("structuredContent")
data = dict(data) if isinstance(data, dict) else {}
if messages:
data["user_messages"] = messages
if answers:
data["user_answers"] = answers
if waiting:
data["still_waiting"] = waiting
data["user_notice"] = (
"The user sent new input in the AI workbench. Read user_messages / user_answers and follow them; you may "
"reply in your chat or in Anomalous (report(reply_to=<its id>)), but once you and the user settle what to do, "
"record it with log_decision so the decision stays in Anomalous; "
"kind=change messages are changes the user made themselves while you worked: keep them, do not undo them; "
"kind=marks messages are places the user marked on a picture to change (the marked picture is attached): "
"say what you see and how you would fix it (show_panel can show the picture with your own marks), "
"ask_user which fix to do, and log_decision the fix chosen (related: the marks' id); "
"kind=toolbox messages are the nodes, combos, workflows, prompts, models and ways the user picked for you to use; "
"kind=stop: the user stopped your run, so do not run it again until they say what to change; "
"kind=pictures: pictures the user handed you (attached), with made_with read from each picture's own workflow."
)
pictures = ai_marks.image_items(messages)
result["structuredContent"] = data
content = list(result.get("content") or [])
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
content[0] = {"type": "text", "text": tool_text(data)}
else:
content.insert(0, {"type": "text", "text": tool_text(data)})
result["content"] = content + pictures
return result