Files
DemonGatanjieu-Anomalous_Mo…/api/ai_outside.py
T
DemonGatanjieuandClaude Opus 5.5 142074a320 Show what changed outside Anomalous while an AI works
Anomalous sees only the calls made to it; an AI app's own commands and
scripts are invisible, and one rewrote a result six times. When the AI
calls, Anomalous now looks at the top of the output and input folders
for pictures no ComfyUI run saved and pictures rewritten in place since
its previous call (by the files' own times, bounded, never in the
background; new pictures wait while a prompt runs), shows them as an
"Outside AMB" step and tells the AI with its answer (ai_outside.py).
log_outside is the AI's own word on a command or script it ran.

The connect guide gains rules to paste into the AI app's own rules:
use AMB's tools, change pictures only by running workflows, ask and
explain in AMB, and say what was done outside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:16:28 +08:00

198 lines
9.1 KiB
Python

"""What happened outside Anomalous while an AI works on a task, as steps of kind "outside" in the
AI workbench. Anomalous sees only the calls made to it: an AI app may also run commands and
scripts of its own, and one that edits pictures that way loses their workflow and the user's
earlier result. Two ways to see it:
check() looks, when the AI calls Anomalous (never in the background), at what changed since its
previous call: pictures in ComfyUI's output folder no ComfyUI run saved, and pictures in the
output or input folder rewritten in place (changed a while after they were made). Only the top of
each folder, by the files' own times (made, changed), at most MAX_ENTRIES entries. While ComfyUI
runs a prompt, new pictures wait until it ends (its history names them then). What it finds is
a step and goes to the AI with its next answer (tell), which says it may well be the user's own
doing. Without a creation time (Linux), output pictures are "new or changed" and the input
folder is not looked at (an upload looks the same).
log() is the AI's own word: what it ran or changed outside Anomalous, why, and which files.
"""
import os
import threading
import time
import uuid
from collections import OrderedDict
import folder_paths
from . import ai_tasks
from .mcp_tools import tool_text
PICTURES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".mp4", ".webm", ".mov"}
MAX_ENTRIES = 20000 # of a folder's top looked at in one check
MAX_FOUND = 12 # files named in one step
REWRITE_SECONDS = 60 # changed this long after it was made: rewritten, not just written
HISTORY_ITEMS = 300 # ComfyUI's latest runs whose saved pictures are no news
MAX_WATCHED = 16 # tasks whose last check is kept
MAX_TEXT, MAX_FILES, MAX_FILE = 300, 20, 200
_lock = threading.Lock()
_watched = OrderedDict() # task id -> {"since": time of the last full check, "told": {(folder, name, changed)}}
def _queue():
from server import PromptServer
return PromptServer.instance.prompt_queue
def _busy():
"""Whether ComfyUI runs a prompt now (its pictures are in no history yet)."""
try:
running, _pending = _queue().get_current_queue_volatile()
except Exception: # noqa: BLE001 - no server (tests) or an older queue: nothing runs that we know of
return False
return bool(running)
def _saved_by_runs():
"""Names of the pictures ComfyUI's latest runs saved at the top of the output folder."""
try:
history = _queue().get_history(max_items=HISTORY_ITEMS, map_function=lambda record: record.get("outputs") or {})
except Exception: # noqa: BLE001 - no server (tests)
return set()
names = set()
for outputs in history.values():
for output in outputs.values() if isinstance(outputs, dict) else ():
for items in output.values() if isinstance(output, dict) else ():
for item in items if isinstance(items, list) else ():
if isinstance(item, dict) and item.get("type") == "output" and not item.get("subfolder") and item.get("filename"):
names.add(item["filename"])
return names
def _times(entry):
"""(made, changed) of a folder entry; made is None where the system keeps no creation time."""
stat = entry.stat()
return getattr(stat, "st_birthtime", None), stat.st_mtime
def _changes(folder, root, since):
"""[(name, change, when)] of the pictures at the top of `root` changed after `since`:
"rewritten", "new" or (no creation time) "changed"."""
found = []
try:
with os.scandir(root) as entries:
for at, entry in enumerate(entries):
if at >= MAX_ENTRIES:
break
if os.path.splitext(entry.name)[1].lower() not in PICTURES or not entry.is_file(follow_symlinks=False):
continue
made, changed = _times(entry)
if changed <= since and (made is None or made <= since):
continue
if made is None:
found.append((entry.name, "changed", changed))
elif changed - made > REWRITE_SECONDS:
found.append((entry.name, "rewritten", changed))
elif made > since:
found.append((entry.name, "new", changed))
except OSError:
return []
return [item for item in found if folder == "output" or item[1] == "rewritten"]
def check(task_id, now=None):
"""What changed outside Anomalous since the last check of `task_id`, as a step of the task
(also returned), or None. The first check of a task only starts the watch."""
if not task_id:
return None
now = time.time() if now is None else now
with _lock:
watch = _watched.get(task_id)
if watch is None:
_watched[task_id] = {"since": now, "told": set()}
while len(_watched) > MAX_WATCHED:
_watched.popitem(last=False)
return None
_watched.move_to_end(task_id)
busy = _busy()
saved = set() if busy else _saved_by_runs()
files = []
for folder, root in (("output", folder_paths.get_output_directory()), ("input", folder_paths.get_input_directory())):
for name, change, when in _changes(folder, root, watch["since"]):
if busy and change != "rewritten":
continue # maybe the running prompt's: judged once it ends
if change != "rewritten" and name in saved:
continue
if (folder, name, when) not in watch["told"]:
watch["told"].add((folder, name, when))
files.append({"folder": folder, "name": name, "change": change})
if not busy:
# A file's change time can be a second or two after the clock read (Windows): kept as told
# until it is older than the next check's start.
watch.update(since=now, told={key for key in watch["told"] if key[2] > now})
if not files:
return None
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "outside_files", "kind": "outside", "ok": True,
"state": "done", "summary": "outside_files", "files": files[:MAX_FOUND], "more": max(0, len(files) - MAX_FOUND),
"results": []}
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
return None
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return step
def tell(result, step):
"""Puts what check() found into the AI's tool result."""
if not step or not isinstance(result, dict):
return result
files = [f"{item['folder']}/{item['name']} ({item['change']})" for item in step["files"]]
note = ("Anomalous saw pictures change outside its tools since your previous call; it shows them to the user. "
"It may be the user's own doing. If it was yours: never edit, crop, paint or overwrite pictures with code "
"of your own (that loses their workflow and the user's earlier result); change a picture by running a "
"ComfyUI workflow, look at one with zoom_image, and say what you ran outside Anomalous with log_outside.")
if result.get("isError"):
result["content"] = list(result.get("content") or []) + [{"type": "text", "text": f"{note} Files: {', '.join(files)}"}]
return result
data = result.get("structuredContent")
data = dict(data) if isinstance(data, dict) else {}
data["outside_changes"], data["outside_note"] = files, note
result["structuredContent"] = data
content = list(result.get("content") or [])
text = {"type": "text", "text": tool_text(data)}
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
content[0] = text
else:
content.insert(0, text)
result["content"] = content
return result
def _line(value, limit, what):
value = str(value or "").strip()
if len(value) > limit:
raise ValueError(f"{what} is longer than {limit} characters.")
return value
def log(client, what, why="", files=None, now=None, session="", task_id=None):
"""The AI's word on what it did outside Anomalous, as a step of its task. Returns the step."""
what = _line(what, MAX_TEXT, "what")
if not what:
raise ValueError("what is empty: the command, script or change you made outside Anomalous.")
if files is not None and not isinstance(files, list):
raise ValueError("files is a list of the files you read, wrote or changed.")
named = [_line(item, MAX_FILE, "a file") for item in (files or [])[:MAX_FILES] if str(item or "").strip()]
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "log_outside", "kind": "outside", "ok": True,
"state": "done", "summary": f"log_outside: {what[:80]}", "what": what, "why": _line(why, MAX_TEXT, "why"),
"files": named, "results": []}
with ai_tasks._lock:
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task["id"])
return step