Files
DemonGatanjieu-Anomalous_Mo…/api/mcp_tool_sets.py
T
DemonGatanjieuandClaude Opus 5.5 32e6c6116c Tell an AI on the short tool list of the ready-made ways and the skills
A Flash exam on ?tools=core (four questions in Antigravity's own chat, 111 calls, 12% failed, no
detours, every run done) found that without begin_task nothing told the AI of the ways and the
skills: it wired an enlargement node by node. In a set without begin_task, get_situation now
carries the blocks this ComfyUI can run (one at a time: a plan of steps waits for the user in
Anomalous) and the skills unless the task is private, on every call, since a new chat of the same
app within 20 minutes goes on in the same task. On the retake Flash fetched staged_picture and
built with add_nodes(use='refine'). add_nodes' message for a call without use, block or nodes
no longer says the blocks are only in begin_task's answer. Round recorded in tool-exam.md.

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

146 lines
7.0 KiB
Python

"""Which tools an AI app is offered. The address as it is offers every tool; `?tools=core` offers
Anomalous's short set for working in the AI app's own chat, `?tools=mine` the user's own choice (kept in
anomalous/ai_tool_sets.json, picked in the Connect an AI window). An app keeps the whole list in the AI's
context for the whole chat, so a shorter one leaves the AI more room (issue #27). It only shortens the
list: tools/call still takes every tool, and an unknown set name, or "mine" never chosen, offers all.
The short set leaves out the AI workbench (begin_task, report, ask_user…), the tools that move, scan or
download files, and the voice; it keeps the ways and skills (get_toolbox, read_guide), the closer look
(zoom_image) and stop_run. The instructions an app is given name only listed tools (instructions()):
a part naming a tool the set leaves out is dropped, and without the workbench the AI is told to ask and
explain in its own chat. The card's own tools (mcp_apps.APP_TOOLS) come along whenever a listed tool
shows the card. Without begin_task, nothing told the AI of the ready-made ways and the skills (a Flash
exam wired an enlargement node by node): get_situation carries them (chat_start()), every time, since a new
chat of the same app within 20 minutes goes on in the same task and cannot be told from the old one.
GET /anomalous/ai_tool_sets: {core, mine, needed, tools: [{name, stage, size}], all_size};
POST {mine: [names]} keeps the user's choice (a write: this computer only, local_guard.py).
"""
import json
import os
import re
from aiohttp import web
from . import action_catalog, ai_skills, ai_tasks, mcp_apps, reference_blocks
from .mcp_canvas_edit import object_info
from .mcp_tools import ToolError, retext
from .path_utils import atomic_write_json
CORE = (
"get_situation", "describe_canvas", "find_nodes", "check_workflow_models", "run_status",
"library_overview", "scan_report", "search_images", "get_image_info", "zoom_image", "list_combos",
"list_workflows", "list_saved_prompts", "search_audio", "recent_activity", "search_models", "get_model",
"set_prompt", "set_model", "add_lora", "place_combo", "download_missing_models", "run_workflow", "stop_run",
"open_in_anomalous", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow",
"open_workflow", "get_toolbox", "read_guide",
)
NEEDED = ("begin_task", "report", "ask_user", "get_answers") # what a task handed over in Anomalous needs
CHAT_NOTE = ("This connection has no workbench tools: ask the user and explain in your own chat, also where an "
"answer or a skill says ask_user or report.")
WAYS_NOTE = ("add_nodes(use=…, want=…) builds the best one this ComfyUI can run, wired in: prefer it to wiring "
"nodes yourself. A plan of several steps waits for the user in Anomalous, so here build them one at a time.")
_mine = None # the user's choice, read once
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tool_sets.json")
def mine():
"""The tools the user picked, in the registry's order; () before they ever picked."""
global _mine
if _mine is None:
try:
with open(_path(), encoding="utf-8") as source:
stored = json.load(source).get("mine")
except (OSError, ValueError, AttributeError):
stored = None
_mine = tuple(name for name in stored if isinstance(name, str)) if isinstance(stored, list) else ()
return _mine
def save_mine(names, tools):
"""Keeps the user's choice: names of offered tools (the card's own come along by themselves)."""
global _mine
if not isinstance(names, list):
raise ValueError("Expected {mine: [tool names]}")
picked = tuple(name for name in tools if name in names and name not in mcp_apps.APP_TOOLS)
if not picked:
raise ValueError("Pick at least one tool")
atomic_write_json(_path(), {"mine": list(picked)})
_mine = picked
return picked
def chosen(value, tools):
"""The names the address's ?tools= offers, or None for every tool."""
picked = CORE if value == "core" else mine() if value == "mine" else ()
names = {name for name in picked if name in tools}
if not names:
return None
if names & set(mcp_apps.CARD_TOOLS):
names |= set(mcp_apps.APP_TOOLS)
return frozenset(names)
def instructions(parts, names, tools):
"""The server's instructions for the set `names` (None: all of them, as written)."""
if names is None:
return "".join(parts)
pattern = re.compile(r"(?<![\w/])(" + "|".join(sorted(map(re.escape, tools), key=len, reverse=True)) + r")(?!\w)")
kept = [part for part in parts if set(pattern.findall(part)) <= names]
text = "".join(kept).rstrip()
if text.endswith(";"):
text = text[:-1] + "."
if not set(NEEDED) <= names:
text = f"{text} {CHAT_NOTE}"
return text
def chat_start(result, name, names, ctx, private):
"""In a set without begin_task, get_situation also says what begin_task would: the
ready-made ways this ComfyUI can run (a plan of steps is left to Anomalous's own tasks) and the skills
(not in a private task, as begin_task)."""
data = result.get("structuredContent") if isinstance(result, dict) and not result.get("isError") else None
if names is None or "begin_task" in names or name != "get_situation" or not isinstance(data, dict):
return result
try:
info = object_info(ctx)
except ToolError:
info = None
extra = {"blocks": {"note": WAYS_NOTE, **reference_blocks.directory(info)}}
if not private and ai_skills.directory():
extra["skills"] = ai_skills.directory()
retext(result, {**data, **extra})
return result
def register_routes(app, tools, sizes):
"""GET/POST /anomalous/ai_tool_sets; `sizes()` gives {name: characters of its listed spec}."""
def answer():
size = sizes()
offered = [name for name in tools if name not in mcp_apps.APP_TOOLS]
return web.json_response({
"core": list(CORE), "mine": list(mine()), "needed": list(NEEDED), "all_size": sum(size.values()),
"card": {"tools": list(mcp_apps.CARD_TOOLS), "size": sum(size[name] for name in mcp_apps.APP_TOOLS)},
"tools": [{"name": name, "stage": action_catalog.TOOLS[name].stage if name in action_catalog.TOOLS else None,
"size": size[name]} for name in offered],
})
async def api_tool_sets(request):
if request.method == "POST":
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
try:
save_mine((body or {}).get("mine") if isinstance(body, dict) else None, tools)
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
return answer()
app.router.add_get("/anomalous/ai_tool_sets", api_tool_sets)
app.router.add_post("/anomalous/ai_tool_sets", api_tool_sets)