Files
DemonGatanjieuandClaude Opus 5.5 c45c3b672c After the Flash exam: kept bases are no outside change, one pass picks no base
Three exams with Gemini 3.8 Flash on an isolated ComfyUI all went the right
way. They showed that a base kept by Anomalous was reported as a file changed
outside it, and that the one-pass route still told the AI to show bases
first. begin_task's words are shorter: with a real node list it went past
its 4000 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:07:29 +08:00

204 lines
9.4 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, deque
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)}}
_own = deque(maxlen=200) # pictures Anomalous itself put at the top of the output folder
def own(name):
"""Picture `name` was put there by Anomalous (a base the user picked, ai_bases.py): no news."""
_own.append(name)
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 or name in _own):
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