Go on in the task the user wrote to, not a new one
When the user wrote to an older task while the AI had moved on, the AI's next call (once its own task ended or paused) started a new task, where report(reply_to=...) could not find the message. Now an app with no open task goes back to the newest task of its own holding a reply it has not read, and opens it again; an open task it is busy with stays. Naming the calling app moves to api/ai_callers.py to keep ai_tasks.py under its size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ea9d446151
commit
fd952fb9ce
+4
-1
@@ -335,7 +335,10 @@ covered by the `styles.css` manifest.
|
||||
mode — `ai_task_canvas.set_mode` adds a `user_canvas` step — and stops); `GET` of a task adds
|
||||
`decisions` and `decisions_gap` (runs and canvas changes since the AI's last one), begin_task
|
||||
repeats them to an AI picking the task up. `ui_ai_decisions.js` draws the card and the side
|
||||
column's Decisions view. `tests/test_ai_decisions.py`. `api/ai_task_links.py` keeps which task
|
||||
column's Decisions view. `tests/test_ai_decisions.py`. `api/ai_callers.py` names who calls (the
|
||||
app from its client info or User-Agent, a session, a task it names); an app whose task ended or
|
||||
paused goes on in the task the user wrote to and it has not read (`ai_tasks._take_replied`,
|
||||
which opens it again). `api/ai_task_links.py` keeps which task
|
||||
a task goes on from (`from`: set when an AI begins a new task while on another, or the user
|
||||
starts one while looking at a task; changed or taken off by the user, never a loop); `GET` of a
|
||||
task adds `links` {from, next}, begin_task gives an AI the task it continues and what was
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
## v1.62.0 — unreleased
|
||||
|
||||
- AI 已经把任务做完了,你再留言或在图上标注:任务重新打开,留言框上方出现那句一键复制的话(“我在 Anomalous 里回复了,看看再接着做”),AI 回来接着在这个任务里做、回复也挂在你那条下面,不再另开一个新任务。
|
||||
- When the AI had already finished a task and you write to it or mark a picture, the task opens again: the line to copy for the AI shows over the message box, and the AI goes on in this task, answering under what you wrote, instead of starting a new one.
|
||||
- AI 已经把任务做完了,你再留言或在图上标注:任务重新打开,留言框上方出现那句一键复制的话(“我在 Anomalous 里回复了,看看再接着做”),AI 回来接着在这个任务里做、回复也挂在你那条下面,不再另开一个新任务。给更早的任务留言也一样:AI 手上的任务做完或停下后,会回到你留言的那个任务。
|
||||
- When the AI had already finished a task and you write to it or mark a picture, the task opens again: the line to copy for the AI shows over the message box, and the AI goes on in this task, answering under what you wrote, instead of starting a new one. The same holds when you write to an older task while the AI had moved on: once its own task is done or paused, it comes back to the one you wrote to.
|
||||
|
||||
- 同一个东西只用一个名字:界面上的“配方”统一叫 **工作流**(工作流页的卡片、保存和导出、操作记录、备份说明),“配方包”保留,它是发给别人的 .zip;AI 工作台里统一用“素材”“留言”“节点结构”“连接”“副本”“撤销”。
|
||||
- One name for each thing: what the screen called a recipe is a **workflow** everywhere (the Workflows page, saving and exporting, the activity log, backups); a recipe package stays the .zip you give to others. The AI workbench says materials, message, node map and copy throughout.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Who is calling Anomalous's MCP endpoint (mcp_server.py), for the AI workbench's record
|
||||
(ai_tasks.py): a short name for the AI app, the session it runs when it keeps several, and a task
|
||||
it names. Each comes from the request's headers or metadata and is cleaned to a short plain word."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_CLIENT_NAMES = (
|
||||
("claude-code", "Claude Code"), ("claude", "Claude"), ("cursor", "Cursor"), ("cherry", "Cherry Studio"),
|
||||
("windsurf", "Windsurf"), ("codex", "Codex"), ("gemini", "Gemini"), ("vscode", "VS Code"), ("cline", "Cline"),
|
||||
)
|
||||
|
||||
|
||||
def client_of(headers, params):
|
||||
"""A short name for the AI app making a call: the client info it sends, else its User-Agent."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
info = meta.get("io.modelcontextprotocol/clientInfo")
|
||||
raw = info.get("name") if isinstance(info, dict) and isinstance(info.get("name"), str) else ""
|
||||
if not raw:
|
||||
raw = (headers.get("User-Agent") or "").split(" ")[0].split("/")[0]
|
||||
lowered = raw.lower()
|
||||
for key, name in _CLIENT_NAMES:
|
||||
if key in lowered:
|
||||
return name
|
||||
clean = re.sub(r"[^\w .-]", "", raw).strip()[:40]
|
||||
return clean or "AI"
|
||||
|
||||
|
||||
def session_of(headers, params):
|
||||
"""An optional session key when one app runs multiple MCP sessions at once."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
raw = headers.get("Mcp-Session-Id") or headers.get("X-Anomalous-Session") or meta.get("sessionId") or meta.get("conversationId") or ""
|
||||
return re.sub(r"[^\w .:-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
|
||||
|
||||
|
||||
def task_of(headers, params, arguments=None):
|
||||
"""An explicit task id from headers, request metadata or tool arguments."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
arg_task = arguments.get("task") if isinstance(arguments, dict) else None
|
||||
raw = arg_task or headers.get("X-Anomalous-Task") or meta.get("anomalous/task") or meta.get("task") or ""
|
||||
return re.sub(r"[^\w-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
|
||||
+26
-39
@@ -14,7 +14,8 @@ made while an AI works join its task through ai_task_changes.py. A step a restar
|
||||
|
||||
The user can start a task first (New task, POST /anomalous/ai_tasks): it waits for an AI, and
|
||||
the next app that calls takes it over, so the work it does next starts there instead of joining
|
||||
the app's last task.
|
||||
the app's last task. Likewise an app whose task ended or paused goes on in the task the user wrote
|
||||
to (a message, marks, pictures, materials) that it has not read yet, rather than starting one.
|
||||
|
||||
Tasks are files in the ComfyUI user folder (anomalous/ai_tasks/<id>.json), the newest
|
||||
MAX_TASKS kept. Recording never fails a tool call: the record is a convenience. The page
|
||||
@@ -150,43 +151,7 @@ def _notify(task_id, extra=None):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- who is calling
|
||||
|
||||
_CLIENT_NAMES = (
|
||||
("claude-code", "Claude Code"), ("claude", "Claude"), ("cursor", "Cursor"), ("cherry", "Cherry Studio"),
|
||||
("windsurf", "Windsurf"), ("codex", "Codex"), ("gemini", "Gemini"), ("vscode", "VS Code"), ("cline", "Cline"),
|
||||
)
|
||||
|
||||
|
||||
def client_of(headers, params):
|
||||
"""A short name for the AI app making a call: the client info it sends, else its User-Agent."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
info = meta.get("io.modelcontextprotocol/clientInfo")
|
||||
raw = info.get("name") if isinstance(info, dict) and isinstance(info.get("name"), str) else ""
|
||||
if not raw:
|
||||
raw = (headers.get("User-Agent") or "").split(" ")[0].split("/")[0]
|
||||
lowered = raw.lower()
|
||||
for key, name in _CLIENT_NAMES:
|
||||
if key in lowered:
|
||||
return name
|
||||
clean = re.sub(r"[^\w .-]", "", raw).strip()[:40]
|
||||
return clean or "AI"
|
||||
|
||||
|
||||
def session_of(headers, params):
|
||||
"""An optional session key when one app runs multiple MCP sessions at once."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
raw = headers.get("Mcp-Session-Id") or headers.get("X-Anomalous-Session") or meta.get("sessionId") or meta.get("conversationId") or ""
|
||||
return re.sub(r"[^\w .:-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
|
||||
|
||||
|
||||
def task_of(headers, params, arguments=None):
|
||||
"""An explicit task id from headers, request metadata or tool arguments."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
arg_task = arguments.get("task") if isinstance(arguments, dict) else None
|
||||
raw = arg_task or headers.get("X-Anomalous-Task") or meta.get("anomalous/task") or meta.get("task") or ""
|
||||
return re.sub(r"[^\w-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
|
||||
|
||||
# ---------------------------------------------------------------- who is calling (named in ai_callers.py)
|
||||
|
||||
def _owner_key(client, session=""):
|
||||
return f"{client}::{session}" if session else client
|
||||
@@ -319,7 +284,27 @@ def current_task(client, now=None, session="", task_id=None):
|
||||
if task and not task.get("ended"):
|
||||
task["ended"] = task.get("updated")
|
||||
_save(task)
|
||||
return _new_task(client, now=now, session=session)
|
||||
return _take_replied(tasks, client, session) or _new_task(client, now=now, session=session)
|
||||
|
||||
|
||||
REPLIES = {"user_message", "user_marks", "user_pictures", "user_toolbox"} # what the user gives the AI (ai_call_lines.js)
|
||||
|
||||
|
||||
def _take_replied(tasks, client, session="", previous=None):
|
||||
"""The newest task of this app the user wrote to since its AI last read (an ended one opens
|
||||
again), now this app's instead of a new one (its own open task `previous` ends), or None."""
|
||||
replied = max((task for task in tasks.values() if task.get("client") == client and any(
|
||||
step.get("kind") == "user" and step.get("tool") in REPLIES and not step.get("delivered") for step in task["steps"])),
|
||||
key=lambda task: task.get("updated", 0), default=None)
|
||||
if replied is None:
|
||||
return None
|
||||
if previous and previous is not replied and not previous.get("ended"):
|
||||
previous["ended"] = previous.get("updated")
|
||||
_save(previous)
|
||||
replied["ended"] = None
|
||||
_bind_owner(client, session, replied["id"])
|
||||
_save(replied)
|
||||
return replied
|
||||
|
||||
|
||||
def begin_task(client, title, now=None, new_task=False, session="", task_id=None):
|
||||
@@ -333,6 +318,8 @@ def begin_task(client, title, now=None, new_task=False, session="", task_id=None
|
||||
task = _take_waiting(client, now, session=session)
|
||||
if task is None and not new_task and previous and not previous.get("ended") and now - previous.get("updated", 0) < IDLE_SECONDS:
|
||||
task = previous
|
||||
if task is None and not new_task:
|
||||
task = _take_replied(tasks, client, session, previous)
|
||||
if task is None:
|
||||
if previous and not previous.get("ended"):
|
||||
previous["ended"] = now
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import action_catalog, ai_gates, ai_outside, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_prompts
|
||||
from . import action_catalog, ai_callers, ai_gates, ai_outside, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_prompts
|
||||
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
|
||||
from .mcp_actions import ACTIONS
|
||||
from .mcp_canvas_edit import CANVAS_TOOLS
|
||||
@@ -187,9 +187,9 @@ async def _call(params, request):
|
||||
if not isinstance(name, str) or name not in TOOLS or not isinstance(raw_arguments, dict):
|
||||
return None
|
||||
arguments = dict(raw_arguments)
|
||||
client = ai_tasks.client_of(request.headers, params)
|
||||
session = ai_tasks.session_of(request.headers, params)
|
||||
task_id = ai_tasks.task_of(request.headers, params, arguments)
|
||||
client = ai_callers.client_of(request.headers, params)
|
||||
session = ai_callers.session_of(request.headers, params)
|
||||
task_id = ai_callers.task_of(request.headers, params, arguments)
|
||||
if "task" in arguments and "task" not in TOOLS[name]["spec"]["inputSchema"].get("properties", {}):
|
||||
arguments.pop("task", None)
|
||||
ctx = {"base_url": f"{request.scheme}://{request.host}", "client": client, "session": session, "task": task_id} # loopback, checked in api_mcp
|
||||
|
||||
+29
-5
@@ -13,7 +13,7 @@ from unittest import mock
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import action_catalog, activity_log, ai_panels, ai_task_changes, ai_task_runs, ai_tasks, ai_tasks_routes, mcp_task_tools # noqa: E402
|
||||
from api import action_catalog, activity_log, ai_callers, ai_panels, ai_task_changes, ai_task_runs, ai_tasks, ai_tasks_routes, mcp_task_tools # noqa: E402
|
||||
from api.mcp_server import TOOLS # noqa: E402
|
||||
|
||||
TMP = tempfile.mkdtemp(prefix="amb_ai_tasks_")
|
||||
@@ -314,11 +314,35 @@ class TaskTests(unittest.TestCase):
|
||||
ai_tasks.record("A", "add_lora", {}, ok({"_activity": entry["id"]}), ai_tasks.time.time())
|
||||
self.assertEqual(activity_log.list_entries()[0]["task"], ai_tasks.list_tasks()[0]["id"])
|
||||
|
||||
def test_the_ai_goes_on_in_the_task_the_user_wrote_to(self):
|
||||
ai_tasks.begin_task("A", "睡衣少女")
|
||||
ai_tasks.report("A", "做完了", done=True)
|
||||
old_id = ai_tasks.list_tasks()[0]["id"]
|
||||
ai_tasks.begin_task("A", "另一件事", new_task=True)
|
||||
ai_tasks.report("A", "也做完了", done=True)
|
||||
self.assertNotEqual(ai_tasks.list_tasks()[0]["id"], old_id)
|
||||
asked = ai_panels.leave_message(old_id, "这根手指怪怪的")
|
||||
ai_tasks.record("A", "describe_canvas", {}, ok({}), ai_tasks.time.time(), True)
|
||||
self.assertEqual(ai_tasks.get_task(old_id)["steps"][-1]["tool"], "describe_canvas", "its next call lands in the task written to")
|
||||
self.assertIsNone(ai_tasks.get_task(old_id)["ended"])
|
||||
ai_panels.attach_pending(ok({}), "A")
|
||||
self.assertTrue(mcp_task_tools.report({"client": "A"}, "改好了", reply_to=asked["id"])["noted"])
|
||||
|
||||
def test_a_reply_elsewhere_does_not_pull_the_ai_off_its_open_task(self):
|
||||
ai_tasks.begin_task("A", "旧的")
|
||||
ai_tasks.report("A", "做完了", done=True)
|
||||
old_id = ai_tasks.list_tasks()[0]["id"]
|
||||
ai_tasks.begin_task("A", "正在做", new_task=True)
|
||||
busy = ai_tasks.list_tasks()[0]["id"]
|
||||
ai_panels.leave_message(old_id, "顺便看看这个")
|
||||
ai_tasks.record("A", "describe_canvas", {}, ok({}), ai_tasks.time.time(), True)
|
||||
self.assertEqual(ai_tasks.get_task(busy)["steps"][-1]["tool"], "describe_canvas")
|
||||
|
||||
def test_client_names(self):
|
||||
self.assertEqual(ai_tasks.client_of({}, {"_meta": {"io.modelcontextprotocol/clientInfo": {"name": "claude-code"}}}), "Claude Code")
|
||||
self.assertEqual(ai_tasks.client_of({"User-Agent": "Cursor/1.2 (win)"}, {}), "Cursor")
|
||||
self.assertEqual(ai_tasks.client_of({"User-Agent": "my<tool>/1"}, {}), "mytool")
|
||||
self.assertEqual(ai_tasks.client_of({}, {}), "AI")
|
||||
self.assertEqual(ai_callers.client_of({}, {"_meta": {"io.modelcontextprotocol/clientInfo": {"name": "claude-code"}}}), "Claude Code")
|
||||
self.assertEqual(ai_callers.client_of({"User-Agent": "Cursor/1.2 (win)"}, {}), "Cursor")
|
||||
self.assertEqual(ai_callers.client_of({"User-Agent": "my<tool>/1"}, {}), "mytool")
|
||||
self.assertEqual(ai_callers.client_of({}, {}), "AI")
|
||||
|
||||
def test_routes(self):
|
||||
ai_tasks.record("A", "describe_canvas", {}, ok({}), 1.0, True)
|
||||
|
||||
Reference in New Issue
Block a user