The tool list sits in the AI's context every round and is at its budget (issue #27). The address may now name a shorter one: ?tools=core (drawing in the AI app's own chat: no workbench, no file moves, scans or downloads, no voice; the ways, skills, zoom_image and stop_run stay) or ?tools=mine (the user's checklist by stage, kept in user/anomalous/ai_tool_sets.json). Only the list shrinks: tools/call takes every tool, an unknown or empty set offers all, and the address as it is answers exactly as before. The instructions are split into parts and a part naming a tool outside the set is dropped; without the workbench the AI is told to ask in its own chat. Connect an AI picks the set above the address (not under a waiting task, which needs the workbench); the address, the sentence and each app's line follow. Users pick from Anomalous's tools and never add their own (AD-027). The structure check passed absolute paths for every web file on one command line, which grew past what Windows takes; it now passes them relative to the plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
5.5 KiB
Python
122 lines
5.5 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.
|
|
|
|
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_tasks, mcp_apps
|
|
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.")
|
|
_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 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)
|