- run_workflow takes a gallery picture as its workflow (mcp_fill.opening says what a name opens, for open_workflow too); a picture's workflow opens with an id of its own, and the tab's mark goes into the workflow before it loads, so ComfyUI finishing the tab does not drop it. - With ComfyUI open in two browser tabs a task's actions were split between them (one opened the task's tab, the other could not find it): the page that did the last action gets the next first now, another only when it is gone (mcp_bridge.py, pages keep an id of their own). - describe_canvas on a big canvas: titles off, then nodes as a line each with their settings, so every value stays readable. - search_models names the models that did not fit; begin_task's tool directory goes by name alone when the answer would be too long. - ask_user refuses a "test" question with made-up options too. - Exam round recorded: question 2 with nothing filed away, a big canvas read right in 3 calls, question 3 in 12 calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
5.3 KiB
Python
118 lines
5.3 KiB
Python
"""A directory of the tools by what an AI wants to do, so it goes straight to the right one, as
|
|
ARCHITECTURE.md does for the code: each tool under its stage of the work (action_catalog.TOOLS),
|
|
with its title, in the catalog's order. It is built from the registry, so a new tool shows up by
|
|
itself (tests/test_mcp_directory.py checks that every stage has a heading). mcp_server.py puts it
|
|
into begin_task's answer, and into get_situation's while there is no task yet.
|
|
|
|
AI apps keep the tool list they fetched (Antigravity files it away and fetches it again only when
|
|
it reconnects), so after Anomalous is updated an app can go on with the old one: new tools missing,
|
|
gone ones still called. listed() keeps which list each app fetched last (a fingerprint, in
|
|
anomalous/ai_tool_lists.json); a call from an app whose list is not today's tells the AI, and notes
|
|
it on the task (task["stale_tools"]) so the workbench tells the user how to reconnect, until the app
|
|
fetches the list again.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
from . import action_catalog, ai_tasks, mcp_fit
|
|
from .mcp_tools import retext
|
|
from .path_utils import atomic_write_json
|
|
|
|
SECTIONS = ( # stage -> heading, in the order work usually goes
|
|
("run", "Run a workflow"), ("canvas", "The workflow on the canvas"), ("model", "Models"), ("prompt", "Prompts"),
|
|
("combo", "Combos"), ("picture", "Pictures"), ("voice", "Voices"), ("library", "The library"),
|
|
(None, "The task and the user"),
|
|
)
|
|
NOTE = "The tools by what you want to do: go straight to the one you need instead of looking around first."
|
|
STALE_NOTE = ("Your app holds an older list of Anomalous's tools (Anomalous was updated since it fetched it): some may "
|
|
"be missing or gone. The call went through, but tell the user in report to reconnect the app to Anomalous "
|
|
"(Antigravity: restart it, or switch the anomalous MCP server off and on) and to go on in a new chat.")
|
|
_lists = None # app -> fingerprint of the tool list it fetched last
|
|
|
|
|
|
def directory(specs, titles=True):
|
|
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}; the names alone without `titles`."""
|
|
sections = {}
|
|
for name, entry in action_catalog.TOOLS.items():
|
|
if name in specs:
|
|
sections.setdefault(entry.stage, []).append(f"{name} ({specs[name].get('title') or name})" if titles else name)
|
|
return {"note": NOTE, **{heading: ", ".join(sections[stage]) for stage, heading in SECTIONS if stage in sections}}
|
|
|
|
|
|
def attach(result, name, specs):
|
|
"""Puts the directory into begin_task's answer, and into get_situation's while there is no task."""
|
|
data = result.get("structuredContent") if isinstance(result, dict) and not result.get("isError") else None
|
|
if not isinstance(data, dict) or not (name == "begin_task" or (name == "get_situation" and not data.get("task"))):
|
|
return result
|
|
answer = {**data, "directory": directory(specs)}
|
|
if mcp_fit.size(answer) > mcp_fit.BUDGET: # a long message of the user's with it: the tools by name, to stay readable
|
|
answer["directory"] = directory(specs, titles=False)
|
|
retext(result, answer)
|
|
return result
|
|
|
|
|
|
def fingerprint(tools):
|
|
return hashlib.sha256(json.dumps(tools, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _path():
|
|
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tool_lists.json")
|
|
|
|
|
|
def _known():
|
|
global _lists
|
|
if _lists is None:
|
|
try:
|
|
with open(_path(), encoding="utf-8") as source:
|
|
stored = json.load(source)
|
|
except (OSError, ValueError):
|
|
stored = {}
|
|
_lists = stored if isinstance(stored, dict) else {}
|
|
return _lists
|
|
|
|
|
|
def listed(client, tools):
|
|
"""The app `client` fetched `tools`: kept, and the notes on its tasks go."""
|
|
with ai_tasks._lock:
|
|
known = _known()
|
|
if known.get(client) != fingerprint(tools):
|
|
known[client] = fingerprint(tools)
|
|
atomic_write_json(_path(), known)
|
|
changed = [task for task in ai_tasks._load().values() if task.get("stale_tools") and task.get("client") == client]
|
|
for task in changed:
|
|
task.pop("stale_tools")
|
|
ai_tasks._save(task)
|
|
for task in changed:
|
|
ai_tasks._notify(task["id"])
|
|
|
|
|
|
def stale(client, tools):
|
|
"""Whether the app `client` fetched another list than `tools` last (one never seen fetching: no)."""
|
|
with ai_tasks._lock:
|
|
known = _known().get(client)
|
|
return bool(known) and known != fingerprint(tools)
|
|
|
|
|
|
def tell_stale(result, task_id):
|
|
"""Says so in the call's answer, and notes it on the task for the workbench."""
|
|
if isinstance(result, dict):
|
|
data = result.get("structuredContent")
|
|
first = (result.get("content") or [None])[0]
|
|
if isinstance(data, dict) and not result.get("isError"):
|
|
retext(result, {**data, "tool_list": STALE_NOTE})
|
|
elif isinstance(first, dict) and first.get("type") == "text":
|
|
result["content"][0] = {**first, "text": f"{first.get('text', '')} {STALE_NOTE}"}
|
|
if not task_id:
|
|
return result
|
|
with ai_tasks._lock:
|
|
task = ai_tasks._load().get(task_id)
|
|
if task is None or task.get("stale_tools"):
|
|
return result
|
|
task["stale_tools"] = time.time()
|
|
ai_tasks._save(task)
|
|
ai_tasks._notify(task_id)
|
|
return result
|