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>
204 lines
9.4 KiB
Python
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
|