Просмотр исходного кода

support agent communication and tool table link; implement router agent and service agent

guantao 1 неделя назад
Родитель
Сommit
a15d491070
92 измененных файлов с 7985 добавлено и 1086 удалено
  1. 97 0
      chat_with_router.py
  2. 22 0
      data/groups.json
  3. 179 0
      data/registry.json
  4. 42 0
      data/sources.json
  5. 3 0
      data/staging/task_0cd69d84/.env.example
  6. 153 0
      data/staging/task_0cd69d84/main.py
  7. 17 0
      data/staging/task_0cd69d84/pyproject.toml
  8. 221 0
      data/staging/task_0cd69d84/tests/run_comfy/run_workflow_only.py
  9. 46 0
      data/staging/task_0d79bdb3/README.md
  10. 51 0
      data/staging/task_0d79bdb3/comfy_launcher.py
  11. 50 0
      data/staging/task_0d79bdb3/main.py
  12. 11 0
      data/staging/task_0d79bdb3/pyproject.toml
  13. 12 0
      data/staging/task_0d79bdb3/tests/last_run.log
  14. 33 0
      data/staging/task_0d79bdb3/tests/run_comfy/launch_comfy_env.py
  15. 42 0
      data/staging/task_0d79bdb3/tests/test_launch.py
  16. 345 0
      data/staging/task_0d79bdb3/uv.lock
  17. 91 0
      data/staging/task_f4883c77/tests/run_comfy/stop_comfy_env.py
  18. 372 628
      docs/design.md
  19. 491 234
      docs/internal_api.md
  20. 137 0
      docs/tool_table/resource-storage-examples.md
  21. 289 0
      docs/tool_table/resource-storage.md
  22. 290 0
      docs/tool_table/tool-table-integration.md
  23. 3 0
      pyproject.toml
  24. 263 0
      src/im-client/AGENT_GUIDE.md
  25. 191 0
      src/im-client/README.md
  26. 308 0
      src/im-client/client.py
  27. 248 0
      src/im-client/client.py.bak
  28. 22 0
      src/im-client/notifier.py
  29. 23 0
      src/im-client/protocol.py
  30. 227 0
      src/im-client/tools.py
  31. 29 11
      src/tool_agent/__main__.py
  32. BIN
      src/tool_agent/__pycache__/__init__.cpython-312.pyc
  33. BIN
      src/tool_agent/__pycache__/__main__.cpython-312.pyc
  34. BIN
      src/tool_agent/__pycache__/config.cpython-312.pyc
  35. BIN
      src/tool_agent/__pycache__/models.cpython-312.pyc
  36. 3 0
      src/tool_agent/config.py
  37. 13 1
      src/tool_agent/models.py
  38. BIN
      src/tool_agent/registry/__pycache__/__init__.cpython-312.pyc
  39. BIN
      src/tool_agent/registry/__pycache__/registry.cpython-312.pyc
  40. 62 0
      src/tool_agent/registry/groups.py
  41. 139 8
      src/tool_agent/registry/registry.py
  42. BIN
      src/tool_agent/router/__pycache__/__init__.cpython-312.pyc
  43. BIN
      src/tool_agent/router/__pycache__/agent.cpython-312.pyc
  44. BIN
      src/tool_agent/router/__pycache__/dispatcher.cpython-312.pyc
  45. BIN
      src/tool_agent/router/__pycache__/server.cpython-312.pyc
  46. BIN
      src/tool_agent/router/__pycache__/status.cpython-312.pyc
  47. 172 9
      src/tool_agent/router/agent.py
  48. 867 0
      src/tool_agent/router/router_agent.py
  49. 103 153
      src/tool_agent/router/server.py
  50. 11 7
      src/tool_agent/router/status.py
  51. 184 0
      src/tool_agent/router/tool_table_tools.py
  52. BIN
      src/tool_agent/runtime/__pycache__/__init__.cpython-312.pyc
  53. BIN
      src/tool_agent/runtime/__pycache__/docker_runner.cpython-312.pyc
  54. BIN
      src/tool_agent/runtime/__pycache__/local_runner.cpython-312.pyc
  55. 16 13
      src/tool_agent/runtime/local_runner.py
  56. 1 0
      src/tool_agent/service/__init__.py
  57. 241 0
      src/tool_agent/service/agent.py
  58. 151 0
      src/tool_agent/service/session.py
  59. BIN
      src/tool_agent/tool/__pycache__/__init__.cpython-312.pyc
  60. BIN
      src/tool_agent/tool/__pycache__/agent.cpython-312.pyc
  61. 125 10
      src/tool_agent/tool/agent.py
  62. 304 0
      src/tool_agent/tool_table.py
  63. 46 0
      test_router_agent.py
  64. 74 0
      test_router_chat_api.py
  65. 1 4
      tests/tasks/runcomfy_launch_env.json
  66. 0 0
      tests/tasks/runcomfy_run_only.json
  67. 1 1
      tests/tasks/runcomfy_stop_env.json
  68. 106 0
      tests/test_knowhub_api_integration.py
  69. 85 0
      tests/test_knowhub_modify.py
  70. 243 0
      tests/test_knowhub_query.py
  71. 24 0
      tests/test_router_api.py
  72. 1 1
      tools/local/launch_comfy_env/.python-version
  73. 50 0
      tools/local/launch_comfy_env/comfy_launcher.py
  74. 17 4
      tools/local/launch_comfy_env/main.py
  75. 6 2
      tools/local/launch_comfy_env/pyproject.toml
  76. 11 0
      tools/local/launch_comfy_env/tests/last_run.log
  77. 33 0
      tools/local/launch_comfy_env/tests/run_comfy/launch_comfy_env.py
  78. 41 0
      tools/local/launch_comfy_env/tests/test_launch.py
  79. 1 0
      tools/local/runcomfy_stop_env/.python-version
  80. 0 0
      tools/local/runcomfy_stop_env/README.md
  81. 22 0
      tools/local/runcomfy_stop_env/main.py
  82. 12 0
      tools/local/runcomfy_stop_env/pyproject.toml
  83. 49 0
      tools/local/runcomfy_stop_env/stop_service.py
  84. 13 0
      tools/local/runcomfy_stop_env/tests/last_run.log
  85. 28 0
      tools/local/runcomfy_stop_env/tests/test_stop.py
  86. 1 0
      tools/local/task_0cd69d84/.python-version
  87. 0 0
      tools/local/task_0cd69d84/README.md
  88. 140 0
      tools/local/task_0cd69d84/main.py
  89. 13 0
      tools/local/task_0cd69d84/pyproject.toml
  90. 5 0
      tools/local/task_0cd69d84/tests/last_run.log
  91. 193 0
      tools/local/task_0cd69d84/tests/run_comfy/run_workflow_only.py
  92. 69 0
      uv.lock

+ 97 - 0
chat_with_router.py

@@ -0,0 +1,97 @@
+"""Router Agent 命令行交互客户端
+
+用法:
+    uv run python chat_with_router.py
+"""
+
+import asyncio
+import sys
+import httpx
+
+# 修复 Windows 控制台编码问题
+if sys.platform == 'win32':
+    import io
+    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
+
+BASE_URL = "http://127.0.0.1:8001"
+
+
+async def chat_loop():
+    """交互式对话循环"""
+    print("=" * 60)
+    print("Router Agent - 命令行客户端")
+    print("=" * 60)
+    print("提示:")
+    print("  - 输入你的问题或请求")
+    print("  - 输入 'exit' 或 'quit' 退出")
+    print("  - 输入 'help' 查看可用命令")
+    print("-" * 60)
+
+    session_id = None
+
+    async with httpx.AsyncClient(timeout=120.0) as client:
+        # 检查服务是否可用
+        try:
+            await client.get(f"{BASE_URL}/health")
+        except Exception as e:
+            print(f"\n错误: 无法连接到 Router Agent 服务")
+            print(f"请确保服务已启动: uv run python -m tool_agent")
+            print(f"详细错误: {e}")
+            return
+
+        while True:
+            try:
+                # 读取用户输入
+                user_input = await asyncio.to_thread(input, "\n[You] > ")
+                user_input = user_input.strip()
+
+                if not user_input:
+                    continue
+
+                # 处理退出命令
+                if user_input.lower() in ("exit", "quit"):
+                    print("\n再见!")
+                    break
+
+                # 处理帮助命令
+                if user_input.lower() == "help":
+                    print("\n可用命令:")
+                    print("  list              - 列出所有工具")
+                    print("  search <关键词>   - 搜索工具")
+                    print("  status            - 查看工具状态")
+                    print("  exit/quit         - 退出")
+                    print("\n你也可以直接用自然语言提问,例如:")
+                    print("  - 我需要一个图片压缩工具")
+                    print("  - image_stitcher 怎么用?")
+                    print("  - 帮我调用 XXX 工具")
+                    continue
+
+                # 发送消息给 Router Agent
+                print("\n[Router Agent] 思考中...")
+                response = await client.post(
+                    f"{BASE_URL}/chat",
+                    json={
+                        "message": user_input,
+                        "session_id": session_id
+                    }
+                )
+
+                if response.status_code == 200:
+                    result = response.json()
+                    session_id = result.get("session_id")
+                    print(f"\n[Router Agent]\n{result['response']}")
+                else:
+                    print(f"\n错误: {response.status_code} - {response.text}")
+
+            except KeyboardInterrupt:
+                print("\n\n按 Ctrl+C 退出,或输入 'exit'")
+            except EOFError:
+                print("\n\n再见!")
+                break
+            except Exception as e:
+                print(f"\n错误: {e}")
+
+
+if __name__ == "__main__":
+    asyncio.run(chat_loop())

+ 22 - 0
data/groups.json

@@ -0,0 +1,22 @@
+{
+  "groups": [
+    {
+      "group_id": "runcomfy_lifecycle",
+      "name": "RunComfy 生命周期管理",
+      "description": "云端 ComfyUI 环境的完整生命周期管理工具组,包括启动、执行、停止三个步骤",
+      "category": "remote",
+      "tool_ids": [
+        "launch_comfy_env",
+        "runcomfy_workflow_executor",
+        "runcomfy_stop_env"
+      ],
+      "usage_order": [
+        "launch_comfy_env",
+        "runcomfy_workflow_executor",
+        "runcomfy_stop_env"
+      ],
+      "usage_example": "1. 使用 launch_comfy_env 启动云端环境,获取 server_id\n2. 使用 runcomfy_workflow_executor 在该环境上执行工作流,传入 server_id\n3. 使用 runcomfy_stop_env 停止环境释放资源,传入 server_id"
+    }
+  ],
+  "version": "1.0"
+}

+ 179 - 0
data/registry.json

@@ -3,6 +3,7 @@
     {
       "tool_id": "image_stitcher",
       "name": "图片拼接工具",
+      "tool_slug_ids": [],
       "category": "cv",
       "description": "将多张图片按指定方向(水平/垂直/网格)拼接成一张大图。支持间距设置、背景色填充和统一缩放模式。输入输出均为 Base64 编码的 PNG 图片。",
       "input_schema": {
@@ -88,6 +89,7 @@
     {
       "tool_id": "liblibai_controlnet",
       "name": "LibLib ControlNet 图生图",
+      "tool_slug_ids": [],
       "category": "cv",
       "description": "基于 LibLib AI 开放 API 的 ControlNet Canny 图生图工具,支持通过边缘检测控制图像生成",
       "input_schema": {
@@ -179,6 +181,183 @@
       },
       "stream_support": false,
       "status": "active"
+    },
+    {
+      "tool_id": "launch_comfy_env",
+      "name": "Launch ComfyUI Environment",
+      "tool_slug_ids": ["comfyui"],
+      "category": "ai",
+      "description": "启动 RunComfy 云端机器并等待就绪。返回 server_id 用于后续 ComfyUI workflow 执行。需要环境变量 RUNCOMFY_USER_ID 和 API_TOKEN。",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "version_id": {
+            "type": "string",
+            "description": "RunComfy workflow version ID",
+            "default": "90f77137-ba75-400d-870f-204c614ae8a3"
+          },
+          "server_type": {
+            "type": "string",
+            "enum": [
+              "medium",
+              "large",
+              "extra-large",
+              "2x-large",
+              "2xl-turbo"
+            ],
+            "description": "机器规格",
+            "default": "medium"
+          },
+          "duration": {
+            "type": "integer",
+            "description": "预估运行时长(秒)",
+            "default": 3600
+          }
+        }
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "server_id": {
+            "type": "string",
+            "description": "机器唯一标识,用于后续操作"
+          },
+          "comfy_url": {
+            "type": "string",
+            "description": "ComfyUI 访问地址"
+          },
+          "status": {
+            "type": "string",
+            "description": "机器状态"
+          },
+          "usage_instruction": {
+            "type": "string",
+            "description": "使用说明"
+          }
+        },
+        "required": [
+          "server_id",
+          "comfy_url",
+          "status",
+          "usage_instruction"
+        ]
+      },
+      "stream_support": false,
+      "status": "active"
+    },
+    {
+      "tool_id": "runcomfy_workflow_executor",
+      "name": "RunComfy Workflow Executor",
+      "tool_slug_ids": ["comfyui"],
+      "category": "image_generation",
+      "description": "在已就绪的 RunComfy 机器上提交 ComfyUI 工作流,上传输入文件,监听执行状态,下载结果图片(不启动/关闭机器)",
+      "input_schema": {
+        "type": "object",
+        "required": [
+          "server_id",
+          "workflow_api"
+        ],
+        "properties": {
+          "server_id": {
+            "type": "string",
+            "description": "已启动的 RunComfy 机器 ID"
+          },
+          "workflow_api": {
+            "type": "object",
+            "description": "ComfyUI workflow_api.json 内容(字典格式)"
+          },
+          "input_files": {
+            "type": "array",
+            "description": "可选的输入文件列表",
+            "items": {
+              "type": "object",
+              "required": [
+                "filename",
+                "type",
+                "base64_data"
+              ],
+              "properties": {
+                "filename": {
+                  "type": "string",
+                  "description": "文件名"
+                },
+                "type": {
+                  "type": "string",
+                  "description": "文件类型:images/loras/checkpoints/vae/controlnet"
+                },
+                "base64_data": {
+                  "type": "string",
+                  "description": "文件的 Base64 编码数据"
+                }
+              }
+            }
+          }
+        }
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "prompt_id": {
+            "type": "string",
+            "description": "任务 ID"
+          },
+          "images": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            },
+            "description": "结果图片的 Base64 数据列表"
+          },
+          "status": {
+            "type": "string",
+            "description": "执行状态"
+          },
+          "server_id": {
+            "type": "string",
+            "description": "机器 ID"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active"
+    },
+    {
+      "tool_id": "runcomfy_stop_env",
+      "name": "RunComfy Stop Service",
+      "tool_slug_ids": ["comfyui"],
+      "category": "cloud",
+      "description": "Stop and delete RunComfy server instances to release resources. Works with launch_comfy_env for complete lifecycle management.",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "server_id": {
+            "type": "string",
+            "description": "The server ID to stop"
+          }
+        },
+        "required": [
+          "server_id"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "server_id": {
+            "type": "string",
+            "description": "The stopped server ID"
+          },
+          "status": {
+            "type": "string",
+            "description": "Deleted, NotFound, or Error"
+          },
+          "message": {
+            "type": "string",
+            "description": "Detailed result message"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active"
     }
   ],
   "version": "2.0"

+ 42 - 0
data/sources.json

@@ -27,6 +27,48 @@
         "http_method": "POST",
         "internal_port": 8001
       }
+    ],
+    "launch_comfy_env": [
+      {
+        "type": "local",
+        "host_dir": "C:\\Users\\11304\\gitlab\\cybertogether\\tool_agent\\tools\\local\\launch_comfy_env",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/launch",
+        "http_method": "POST",
+        "internal_port": 8001
+      }
+    ],
+    "runcomfy_workflow_executor": [
+      {
+        "type": "local",
+        "host_dir": "C:\\Users\\11304\\gitlab\\cybertogether\\tool_agent\\tools\\local\\task_0cd69d84",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/run",
+        "http_method": "POST",
+        "internal_port": 8000
+      }
+    ],
+    "runcomfy_stop_env": [
+      {
+        "type": "local",
+        "host_dir": "C:\\Users\\11304\\gitlab\\cybertogether\\tool_agent\\tools\\local\\runcomfy_stop_env",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/stop",
+        "http_method": "POST",
+        "internal_port": 8000
+      }
     ]
   }
 }

+ 3 - 0
data/staging/task_0cd69d84/.env.example

@@ -0,0 +1,3 @@
+RUNCOMFY_USER_ID=your_user_id_here
+API_TOKEN=your_api_token_here
+PORT=8000

+ 153 - 0
data/staging/task_0cd69d84/main.py

@@ -0,0 +1,153 @@
+"""RunComfy Workflow HTTP API"""
+
+import base64
+import json
+import os
+import uuid
+from pathlib import Path
+from typing import Optional
+
+import requests
+import websocket
+from dotenv import load_dotenv
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel
+
+load_dotenv()
+
+app = FastAPI(title="RunComfy Workflow API")
+
+BASE_URL = "https://beta-api.runcomfy.net/prod/api"
+USER_ID = os.getenv("RUNCOMFY_USER_ID")
+API_TOKEN = os.getenv("API_TOKEN")
+
+HEADERS = {
+    "Authorization": f"Bearer {API_TOKEN}",
+    "Content-Type": "application/json",
+}
+
+SUBDIR_UPLOAD_MAP = {
+    "images": {"type": "input", "subfolder": ""},
+    "loras": {"type": "input", "subfolder": "loras"},
+    "checkpoints": {"type": "input", "subfolder": "checkpoints"},
+    "vae": {"type": "input", "subfolder": "vae"},
+}
+
+
+class InputFile(BaseModel):
+    filename: str
+    type: str
+    base64_data: str
+
+
+class WorkflowRequest(BaseModel):
+    server_id: str
+    workflow_api: dict
+    input_files: Optional[list[InputFile]] = None
+
+
+class WorkflowResponse(BaseModel):
+    prompt_id: str
+    images: list[str]
+    status: str
+    server_id: str
+
+
+def get_server_url(server_id: str) -> str:
+    resp = requests.get(f"{BASE_URL}/users/{USER_ID}/servers/{server_id}", headers=HEADERS)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("current_status") != "Ready":
+        raise Exception(f"机器未就绪: {data.get('current_status')}")
+    return data["main_service_url"].rstrip("/")
+
+
+def upload_file_from_base64(comfy_url: str, filename: str, base64_data: str, file_type: str, subfolder: str):
+    file_bytes = base64.b64decode(base64_data)
+    files = [("image", (filename, file_bytes, "application/octet-stream"))]
+    data = {"overwrite": "true", "type": file_type, "subfolder": subfolder}
+    resp = requests.post(f"{comfy_url}/upload/image", data=data, files=files)
+    resp.raise_for_status()
+    return resp.json()["name"]
+
+
+def submit_prompt(comfy_url: str, workflow_api: dict, client_id: str) -> str:
+    payload = {"prompt": workflow_api, "client_id": client_id}
+    resp = requests.post(f"{comfy_url}/prompt", json=payload)
+    resp.raise_for_status()
+    return resp.json()["prompt_id"]
+
+
+def wait_for_completion(comfy_url: str, client_id: str, prompt_id: str, timeout: int = 600):
+    scheme = "wss" if comfy_url.startswith("https") else "ws"
+    ws_url = f"{scheme}://{comfy_url.split('://', 1)[-1]}/ws?clientId={client_id}"
+    
+    ws = websocket.WebSocket()
+    ws.settimeout(timeout)
+    ws.connect(ws_url)
+    
+    try:
+        while True:
+            out = ws.recv()
+            if not out or isinstance(out, bytes):
+                continue
+            msg = json.loads(out)
+            
+            if msg.get("type") == "executing":
+                data = msg.get("data", {})
+                if data.get("prompt_id") == prompt_id and data.get("node") is None:
+                    break
+            elif msg.get("type") == "execution_error":
+                if msg.get("data", {}).get("prompt_id") == prompt_id:
+                    raise Exception(f"执行错误: {msg['data'].get('exception_message')}")
+    finally:
+        ws.close()
+
+
+def download_images_as_base64(comfy_url: str, prompt_id: str) -> list[str]:
+    resp = requests.get(f"{comfy_url}/history/{prompt_id}")
+    resp.raise_for_status()
+    outputs = resp.json().get(prompt_id, {}).get("outputs", {})
+    
+    images = []
+    for node_output in outputs.values():
+        if "images" in node_output:
+            for img in node_output["images"]:
+                params = {"filename": img["filename"], "subfolder": img.get("subfolder", ""),
+                         "type": img.get("type", "output")}
+                resp = requests.get(f"{comfy_url}/view", params=params)
+                resp.raise_for_status()
+                images.append(base64.b64encode(resp.content).decode())
+    return images
+
+
+@app.post("/run", response_model=WorkflowResponse)
+async def run_workflow(request: WorkflowRequest):
+    try:
+        comfy_url = get_server_url(request.server_id)
+        client_id = str(uuid.uuid4())
+        
+        if request.input_files:
+            for file in request.input_files:
+                mapping = SUBDIR_UPLOAD_MAP.get(file.type, {"type": "input", "subfolder": file.type})
+                upload_file_from_base64(comfy_url, file.filename, file.base64_data, 
+                                       mapping["type"], mapping["subfolder"])
+        
+        prompt_id = submit_prompt(comfy_url, request.workflow_api, client_id)
+        wait_for_completion(comfy_url, client_id, prompt_id)
+        images = download_images_as_base64(comfy_url, prompt_id)
+        
+        return WorkflowResponse(
+            prompt_id=prompt_id,
+            images=images,
+            status="Success",
+            server_id=request.server_id
+        )
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+if __name__ == "__main__":
+    import uvicorn
+    port = int(os.getenv("PORT", "8000"))
+    uvicorn.run(app, host="0.0.0.0", port=port)

+ 17 - 0
data/staging/task_0cd69d84/pyproject.toml

@@ -0,0 +1,17 @@
+[project]
+name = "runcomfy-workflow-executor"
+version = "0.1.0"
+description = "RunComfy workflow executor API"
+requires-python = ">=3.10"
+dependencies = [
+    "requests>=2.31.0",
+    "websocket-client>=1.6.0",
+    "python-dotenv>=1.0.0",
+    "fastapi>=0.104.0",
+    "uvicorn>=0.24.0",
+    "pydantic>=2.0.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"

+ 221 - 0
data/staging/task_0cd69d84/tests/run_comfy/run_workflow_only.py

@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""RunComfy Workflow Executor (仅执行,不启动/关闭机器)"""
+
+import argparse
+import json
+import os
+import sys
+import time
+import urllib.parse
+import uuid
+from pathlib import Path
+
+import requests
+import websocket
+from dotenv import load_dotenv
+
+load_dotenv()
+
+BASE_URL = "https://beta-api.runcomfy.net/prod/api"
+USER_ID = os.getenv("RUNCOMFY_USER_ID")
+API_TOKEN = os.getenv("API_TOKEN")
+
+HEADERS = {
+    "Authorization": f"Bearer {API_TOKEN}",
+    "Content-Type": "application/json",
+}
+
+SUBDIR_UPLOAD_MAP = {
+    "images": {"type": "input", "subfolder": ""},
+    "loras": {"type": "input", "subfolder": "loras"},
+    "checkpoints": {"type": "input", "subfolder": "checkpoints"},
+    "vae": {"type": "input", "subfolder": "vae"},
+    "controlnet": {"type": "input", "subfolder": "controlnet"},
+    "upscale": {"type": "input", "subfolder": "upscale_models"},
+}
+
+
+def get_server_url(server_id: str) -> str:
+    resp = requests.get(f"{BASE_URL}/users/{USER_ID}/servers/{server_id}", headers=HEADERS)
+    resp.raise_for_status()
+    data = resp.json()
+    status = data.get("current_status", "")
+    if status != "Ready":
+        raise Exception(f"机器未就绪: {status}")
+    return data["main_service_url"].rstrip("/")
+
+
+def upload_file(comfy_url: str, file_path: Path, file_type: str = "input", subfolder: str = "") -> str:
+    with open(file_path, "rb") as f:
+        files = [("image", (file_path.name, f, "application/octet-stream"))]
+        data = {"overwrite": "true", "type": file_type, "subfolder": subfolder}
+        resp = requests.post(f"{comfy_url}/upload/image", data=data, files=files)
+    resp.raise_for_status()
+    server_name = resp.json()["name"]
+    print(f"  上传: {file_path.name} → {subfolder}/{server_name}" if subfolder else f"  上传: {file_path.name}")
+    return server_name
+
+
+def upload_input_dir(comfy_url: str, input_dir: Path) -> dict[str, str]:
+    if not input_dir.exists():
+        print(f"  input 目录不存在: {input_dir}")
+        return {}
+    
+    uploaded = {}
+    IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
+    VIDEO_EXTS = {".mp4", ".avi", ".mov", ".webm"}
+    MODEL_EXTS = {".safetensors", ".ckpt", ".pt", ".pth", ".gguf"}
+    ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS | MODEL_EXTS
+    
+    for f in input_dir.iterdir():
+        if f.is_file() and f.suffix.lower() in ALL_EXTS:
+            server_name = upload_file(comfy_url, f, "input", "")
+            uploaded[f.name] = server_name
+    
+    for subdir in input_dir.iterdir():
+        if not subdir.is_dir():
+            continue
+        mapping = SUBDIR_UPLOAD_MAP.get(subdir.name, {"type": "input", "subfolder": subdir.name})
+        for f in subdir.iterdir():
+            if f.is_file() and f.suffix.lower() in ALL_EXTS:
+                server_name = upload_file(comfy_url, f, mapping["type"], mapping["subfolder"])
+                uploaded[f.name] = server_name
+    
+    return uploaded
+
+
+def submit_prompt(comfy_url: str, workflow_api: dict, client_id: str) -> str:
+    payload = {"prompt": workflow_api, "client_id": client_id}
+    resp = requests.post(f"{comfy_url}/prompt", json=payload)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("node_errors"):
+        print(f"  节点错误: {data['node_errors']}")
+    prompt_id = data["prompt_id"]
+    print(f"任务已提交: {prompt_id}")
+    return prompt_id
+
+
+def wait_for_completion(comfy_url: str, client_id: str, prompt_id: str, timeout: int = 600):
+    scheme = "wss" if comfy_url.startswith("https") else "ws"
+    ws_url = f"{scheme}://{comfy_url.split('://', 1)[-1]}/ws?clientId={client_id}"
+    print("WebSocket 监听中...")
+    
+    ws = websocket.WebSocket()
+    ws.settimeout(timeout)
+    ws.connect(ws_url)
+    
+    try:
+        while True:
+            out = ws.recv()
+            if not out or isinstance(out, bytes):
+                continue
+            msg = json.loads(out)
+            msg_type = msg.get("type", "")
+            data = msg.get("data", {})
+            
+            if msg_type == "executing":
+                node = data.get("node")
+                if data.get("prompt_id") == prompt_id and node is None:
+                    print("  执行完成")
+                    break
+                if node:
+                    print(f"  执行节点: {node}")
+            
+            elif msg_type == "progress":
+                value = data.get("value", 0)
+                max_val = data.get("max", 1)
+                print(f"  进度: {value}/{max_val}")
+            
+            elif msg_type == "execution_error":
+                if data.get("prompt_id") == prompt_id:
+                    raise Exception(f"执行错误: {data.get('exception_message', 'unknown')}")
+    finally:
+        ws.close()
+
+
+def download_outputs(comfy_url: str, prompt_id: str, output_dir: Path) -> list[str]:
+    resp = requests.get(f"{comfy_url}/history/{prompt_id}")
+    resp.raise_for_status()
+    data = resp.json().get(prompt_id, {})
+    outputs = data.get("outputs", {})
+    
+    output_dir.mkdir(parents=True, exist_ok=True)
+    saved = []
+    
+    for node_id, node_output in outputs.items():
+        if "images" in node_output:
+            for image in node_output["images"]:
+                params = {"filename": image["filename"], "subfolder": image.get("subfolder", ""), 
+                         "type": image.get("temp") or image.get("type", "output")}
+                resp = requests.get(f"{comfy_url}/view?{urllib.parse.urlencode(params)}")
+                resp.raise_for_status()
+                out_path = output_dir / image["filename"]
+                out_path.write_bytes(resp.content)
+                print(f"  图片: {out_path}")
+                saved.append(str(out_path))
+        
+        if "gifs" in node_output:
+            for video in node_output["gifs"]:
+                params = {"filename": video["filename"], "subfolder": video.get("subfolder", ""), 
+                         "format": video.get("format", "mp4")}
+                resp = requests.get(f"{comfy_url}/view?{urllib.parse.urlencode(params)}")
+                resp.raise_for_status()
+                out_path = output_dir / video["filename"]
+                out_path.write_bytes(resp.content)
+                print(f"  视频: {out_path}")
+                saved.append(str(out_path))
+    
+    return saved
+
+
+def main():
+    parser = argparse.ArgumentParser(description="RunComfy workflow executor (不启动/关闭机器)")
+    parser.add_argument("--server-id", required=True, help="已启动的机器 ID")
+    parser.add_argument("--workflow", required=True, help="workflow_api.json 路径")
+    parser.add_argument("--input-dir", default="input", help="输入文件目录,默认 input/")
+    parser.add_argument("--output-dir", default="output", help="结果下载目录,默认 output/")
+    parser.add_argument("--skip-upload", action="store_true", help="跳过文件上传")
+    args = parser.parse_args()
+    
+    if not USER_ID or not API_TOKEN:
+        print("ERROR: 请设置 RUNCOMFY_USER_ID 和 API_TOKEN 环境变量")
+        sys.exit(1)
+    
+    workflow_path = Path(args.workflow)
+    if not workflow_path.exists():
+        print(f"ERROR: 文件不存在: {workflow_path}")
+        sys.exit(1)
+    
+    with open(workflow_path, "r", encoding="utf-8") as f:
+        workflow_api = json.load(f)
+    
+    client_id = str(uuid.uuid4())
+    
+    try:
+        print(f"验证机器状态: {args.server_id}")
+        comfy_url = get_server_url(args.server_id)
+        print(f"  ComfyUI URL: {comfy_url}")
+        
+        if not args.skip_upload:
+            print(f"\n上传 input 目录: {args.input_dir}")
+            upload_input_dir(comfy_url, Path(args.input_dir))
+        
+        print(f"\n提交 workflow...")
+        prompt_id = submit_prompt(comfy_url, workflow_api, client_id)
+        
+        wait_for_completion(comfy_url, client_id, prompt_id)
+        
+        print(f"\n下载结果...")
+        saved = download_outputs(comfy_url, prompt_id, Path(args.output_dir))
+        print(f"\n完成,共 {len(saved)} 个文件")
+        print(f"注意: 机器 {args.server_id} 未自动关闭,请手动处理")
+        
+    except Exception as e:
+        print(f"\n错误: {e}")
+        print(f"机器 {args.server_id} 未自动关闭,请手动处理")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 46 - 0
data/staging/task_0d79bdb3/README.md

@@ -0,0 +1,46 @@
+# RunComfy Launcher
+
+启动 RunComfy 云端机器并等待就绪的工具。
+
+## 功能
+- 调用 RunComfy Server API 启动指定版本的云端机器
+- 轮询等待机器状态变为 Ready
+- 提供 HTTP API 接口用于集成
+
+## 环境变量
+- `RUNCOMFY_USER_ID`: RunComfy 用户 ID
+- `API_TOKEN`: RunComfy API Token
+
+## HTTP API
+
+### POST /launch
+启动 RunComfy 机器
+
+**请求参数:**
+```json
+{
+  "version_id": "90f77137-ba75-400d-870f-204c614ae8a3",
+  "server_type": "medium",
+  "duration": 3600
+}
+```
+
+**响应:**
+```json
+{
+  "server_id": "xxx",
+  "comfy_url": "https://...",
+  "status": "Ready",
+  "usage_instruction": "..."
+}
+```
+
+## 命令行使用
+```bash
+uv run python tests/run_comfy/launch_comfy_env.py --version-id xxx --server-type medium
+```
+
+## 测试
+```bash
+uv run python tests/test_launch.py
+```

+ 51 - 0
data/staging/task_0d79bdb3/comfy_launcher.py

@@ -0,0 +1,51 @@
+import os
+import time
+import requests
+
+
+def launch_comfy_server(version_id: str, server_type: str, duration: int, timeout: int = 300):
+    """Launch RunComfy server and wait until ready"""
+    user_id = os.getenv("RUNCOMFY_USER_ID")
+    api_token = os.getenv("API_TOKEN")
+    
+    if not user_id or not api_token:
+        raise ValueError("RUNCOMFY_USER_ID and API_TOKEN must be set")
+    
+    # Create server
+    url = f"https://beta-api.runcomfy.net/prod/api/users/{user_id}/servers"
+    headers = {"Authorization": f"Bearer {api_token}"}
+    payload = {
+        "workflow_version_id": version_id,
+        "server_type": server_type,
+        "estimated_duration": duration
+    }
+    
+    resp = requests.post(url, json=payload, headers=headers)
+    resp.raise_for_status()
+    data = resp.json()
+    server_id = data["server_id"]
+    
+    # Poll until ready
+    check_url = f"{url}/{server_id}"
+    start_time = time.time()
+    
+    while time.time() - start_time < timeout:
+        resp = requests.get(check_url, headers=headers)
+        resp.raise_for_status()
+        server_data = resp.json()
+        
+        if server_data["current_status"] == "Ready":
+            return {
+                "server_id": server_id,
+                "comfy_url": server_data["main_service_url"],
+                "status": "Ready",
+                "usage_instruction": (
+                    f"请使用 run_comfy_workflow 工具,并传入此 server_id ({server_id}) "
+                    "以及你的 workflow_api.json 来生成图片。"
+                    "用完后请务必调用 stop_comfy_env 工具关闭机器。"
+                )
+            }
+        
+        time.sleep(5)
+    
+    raise TimeoutError(f"Server {server_id} not ready within {timeout}s")

+ 50 - 0
data/staging/task_0d79bdb3/main.py

@@ -0,0 +1,50 @@
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+from comfy_launcher import launch_comfy_server
+
+app = FastAPI(title="RunComfy Launcher API")
+
+
+class LaunchRequest(BaseModel):
+    version_id: Optional[str] = "90f77137-ba75-400d-870f-204c614ae8a3"
+    server_type: Optional[str] = "medium"
+    duration: Optional[int] = 3600
+
+
+class LaunchResponse(BaseModel):
+    server_id: str
+    comfy_url: str
+    status: str
+    usage_instruction: str
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}
+
+
+@app.post("/launch", response_model=LaunchResponse)
+def launch(req: LaunchRequest):
+    try:
+        result = launch_comfy_server(
+            version_id=req.version_id,
+            server_type=req.server_type,
+            duration=req.duration
+        )
+        return result
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except TimeoutError as e:
+        raise HTTPException(status_code=504, detail=str(e))
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"启动失败: {str(e)}")
+
+
+if __name__ == "__main__":
+    import argparse
+    import uvicorn
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8002)
+    args = parser.parse_args()
+    uvicorn.run(app, host="0.0.0.0", port=args.port)

+ 11 - 0
data/staging/task_0d79bdb3/pyproject.toml

@@ -0,0 +1,11 @@
+[project]
+name = "launch-comfy-env"
+version = "0.1.0"
+description = "RunComfy server launcher with API polling"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.135.1",
+    "requests>=2.32.5",
+    "uvicorn>=0.42.0",
+]

+ 12 - 0
data/staging/task_0d79bdb3/tests/last_run.log

@@ -0,0 +1,12 @@
+Command: python tests/test_launch.py
+Exit Code: 0
+--- STDOUT ---
+[OK] All imports successful
+[OK] LaunchRequest model validated
+[OK] FastAPI routes configured
+
+[OK] All unit tests passed
+Note: Real API calls require RUNCOMFY_USER_ID and API_TOKEN environment variables
+
+--- STDERR ---
+warning: `VIRTUAL_ENV=C:\Users\11304\gitlab\cybertogether\tool_agent\.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead

+ 33 - 0
data/staging/task_0d79bdb3/tests/run_comfy/launch_comfy_env.py

@@ -0,0 +1,33 @@
+import os
+import sys
+import argparse
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
+
+from comfy_launcher import launch_comfy_server
+
+def main():
+    parser = argparse.ArgumentParser(description='Launch RunComfy environment')
+    parser.add_argument('--version-id', default='90f77137-ba75-400d-870f-204c614ae8a3')
+    parser.add_argument('--server-type', default='medium')
+    parser.add_argument('--duration', type=int, default=3600)
+    parser.add_argument('--timeout', type=int, default=300)
+    
+    args = parser.parse_args()
+    
+    result = launch_comfy_server(
+        version_id=args.version_id,
+        server_type=args.server_type,
+        duration=args.duration,
+        timeout=args.timeout
+    )
+    
+    print(f"Server launched successfully!")
+    print(f"Server ID: {result['server_id']}")
+    print(f"ComfyUI URL: {result['comfy_url']}")
+    print(f"Status: {result['status']}")
+    print(f"\n{result['usage_instruction']}")
+
+if __name__ == '__main__':
+    main()

+ 42 - 0
data/staging/task_0d79bdb3/tests/test_launch.py

@@ -0,0 +1,42 @@
+"""
+Unit test for launch_comfy_env
+Tests code structure without making real API calls
+"""
+import sys
+import os
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+# Test imports
+try:
+    from comfy_launcher import launch_comfy_server
+    from main import app, LaunchRequest
+    print("[OK] All imports successful")
+except Exception as e:
+    print(f"[FAIL] Import failed: {e}")
+    sys.exit(1)
+
+# Test data models
+try:
+    req = LaunchRequest()
+    assert req.version_id == "90f77137-ba75-400d-870f-204c614ae8a3"
+    assert req.server_type == "medium"
+    assert req.duration == 3600
+    print("[OK] LaunchRequest model validated")
+except Exception as e:
+    print(f"[FAIL] Model validation failed: {e}")
+    sys.exit(1)
+
+# Test FastAPI app structure
+try:
+    routes = [route.path for route in app.routes]
+    assert "/launch" in routes
+    assert "/health" in routes
+    print("[OK] FastAPI routes configured")
+except Exception as e:
+    print(f"[FAIL] Route validation failed: {e}")
+    sys.exit(1)
+
+print("\n[OK] All unit tests passed")
+print("Note: Real API calls require RUNCOMFY_USER_ID and API_TOKEN environment variables")

+ 345 - 0
data/staging/task_0d79bdb3/uv.lock

@@ -0,0 +1,345 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
+    { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
+    { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
+    { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
+    { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
+    { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
+    { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
+    { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
+    { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
+    { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
+    { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
+    { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
+    { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
+    { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
+    { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
+    { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
+    { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
+    { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
+    { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
+    { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
+    { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
+    { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
+    { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
+    { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
+    { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
+    { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
+    { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
+    { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
+    { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
+    { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.135.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-doc" },
+    { name = "pydantic" },
+    { name = "starlette" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "launch-comfy-env"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+    { name = "fastapi" },
+    { name = "requests" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.135.1" },
+    { name = "requests", specifier = ">=2.32.5" },
+    { name = "uvicorn", specifier = ">=0.42.0" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
+    { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
+    { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
+    { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
+    { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
+    { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
+    { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
+    { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
+    { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
+    { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
+    { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
+    { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
+    { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+    { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+    { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+    { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+    { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+    { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+    { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+    { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+    { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+    { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+    { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+    { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+    { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.42.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
+]

+ 91 - 0
data/staging/task_f4883c77/tests/run_comfy/stop_comfy_env.py

@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""停止 RunComfy 机器实例
+
+用法:
+    python stop_comfy_env.py --server-id <SERVER_ID>
+"""
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+import requests
+from dotenv import load_dotenv
+
+load_dotenv(Path(__file__).parent.parent.parent / ".env")
+
+BASE_URL = "https://beta-api.runcomfy.net/prod/api"
+USER_ID = os.getenv("RUNCOMFY_USER_ID")
+API_TOKEN = os.getenv("API_TOKEN")
+
+HEADERS = {
+    "Authorization": f"Bearer {API_TOKEN}",
+    "Content-Type": "application/json",
+}
+
+
+def stop_machine(server_id: str) -> dict:
+    """删除指定的机器实例
+    
+    Returns:
+        dict: {
+            "server_id": str,
+            "status": str,  # "Deleted" 或错误信息
+            "message": str
+        }
+    """
+    try:
+        resp = requests.delete(
+            f"{BASE_URL}/users/{USER_ID}/servers/{server_id}",
+            headers=HEADERS
+        )
+        
+        if resp.status_code == 200:
+            return {
+                "server_id": server_id,
+                "status": "Deleted",
+                "message": f"机器 {server_id} 已成功删除"
+            }
+        elif resp.status_code == 404:
+            return {
+                "server_id": server_id,
+                "status": "NotFound",
+                "message": f"机器 {server_id} 不存在或已被删除"
+            }
+        else:
+            return {
+                "server_id": server_id,
+                "status": "Error",
+                "message": f"HTTP {resp.status_code}: {resp.text}"
+            }
+            
+    except Exception as e:
+        return {
+            "server_id": server_id,
+            "status": "Error",
+            "message": f"请求失败: {str(e)}"
+        }
+
+
+def main():
+    parser = argparse.ArgumentParser(description="停止 RunComfy 机器实例")
+    parser.add_argument("--server-id", required=True, help="要关闭的机器 ID")
+    args = parser.parse_args()
+
+    if not USER_ID or not API_TOKEN:
+        print("ERROR: 请设置 RUNCOMFY_USER_ID 和 API_TOKEN 环境变量")
+        sys.exit(1)
+
+    result = stop_machine(args.server_id)
+    print(f"状态: {result['status']}")
+    print(f"消息: {result['message']}")
+    
+    if result["status"] in ("Deleted", "NotFound"):
+        sys.exit(0)
+    else:
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 372 - 628
docs/design.md

@@ -1,736 +1,480 @@
-# Tool Agent 项目设计方案
+# Tool Agent 设计文档
 
-## 1. 项目概述
+## 1. 概述
 
 - 项目名称:tool_agent
-- 项目目标:构建一个自动封装、自动接入、自动部署、自动编写工具的 Agent + 工具库系统
-- 目标用户:其他 Agent 系统(作为工具供应商角色
+- 目标:可自动封装、接入、部署、编写工具的 Agent + 工具库系统
+- 目标用户:其他 Agent 系统(作为工具供应商)
 
-## 2. 系统定位
+## 2. 系统架构
 
-tool_agent 是一个本地常驻的智能工具管理系统,内部由两个 Agent 协作驱动
+tool_agent 由三个核心角色协作
 
 ```
-┌─────────────────────────────────────────────┐
-│              Router Agent                    │
-│  统领全局,维护路由层                         │
-│  - 对外接口管理(FastAPI/MCP/WS)            │
-│  - 请求分发与调度                             │
-│  - 冷热调度、健康监控                         │
-│  - 中间件管理(鉴权/缓存/计量)              │
-├─────────────────────────────────────────────┤
-│              Code Agent                      │
-│  维护工具库                                   │
-│  - 编写/获取/部署新工具                       │
-│  - staging 验证与 promote                    │
-│  - 逆向 API 自修复                           │
-│  - 财务管理(账号注册/API 充值)              │
-└─────────────────────────────────────────────┘
+┌─────────────────────────────────────────────────────────┐
+│                    RouterAgent(常驻)                    │
+│  main 启动时即运行,负责:                                │
+│  - 对外接口(FastAPI: /health, /tools, /run_tool, /chat)│
+│  - CodingAgent 调度(接收任务书 → 创建工具)             │
+│  - 工具注册表 + 状态管理                                 │
+│  - 全局状态监控(工具/CodingAgent/ServiceAgent 窗口)    │
+├─────────────────────────────────────────────────────────┤
+│                  ServiceAgent(按需创建)                 │
+│  每个 chat_id 对应一个独立对话 session:                  │
+│  - 只读查询工具表、工具组、后端运行时、状态              │
+│  - 与外部 Agent 交流,解答工具使用问题                   │
+│  - 撰写任务书 → 提交给 RouterAgent                      │
+│  - 一个实例,多个 chat_id 窗口并发                       │
+├─────────────────────────────────────────────────────────┤
+│                  CodingAgent(按需唤醒)                  │
+│  接收 RouterAgent 的任务书:                              │
+│  - 自主编码、测试、部署工具                              │
+│  - 注册工具到 Registry                                   │
+└─────────────────────────────────────────────────────────┘
 ```
 
-| 角色         | 职责                               | 关注点                              |
-| ------------ | ---------------------------------- | ----------------------------------- |
-| Router Agent | 维护路由层,管理对外接口和内部分发 | "怎么调" — 调度、监控、流量         |
-| Code Agent   | 维护工具库,获取和生产工具         | "有什么可调" — 工具供给、质量、演进 |
+### 2.1 调用层与后端执行环境
 
-两者通过内部消息通信:
-
-- Code Agent 完成新工具 promote 后,通知 Router Agent 更新注册表和路由规则
-- Router Agent 发现工具异常时,通知 Tool Agent 介入修复
-
-## 3. 对外接口
-
-### 3.1 工具目录查询
-
-外部 Agent 请求获取当前可用工具列表,按类别展示,供其挑选。
-
-### 3.2 工具调用执行
-
-外部 Agent 选定工具后发起调用请求,tool_agent 启动对应工具服务,执行并返回结果。
-
-### 3.3 新工具需求响应
-
-外部 Agent 提出工具库中不存在的工具需求时,tool_agent 自主决策获取方式:
-
-| 策略            | 说明                             |
-| --------------- | -------------------------------- |
-| A. 注册购买 API | 直接对接第三方付费/免费 API      |
-| B. 本地部署     | 拉取开源项目,本地或 Docker 部署 |
-| C. 逆向 API     | 注册账号后逆向分析目标服务接口   |
-| D. Browser-Use  | 通过浏览器自动化操作网页工具     |
-
-## 4. 工具库三层架构
+所有工具对外统一为 **本地 Python/FastAPI 调用层**(uv 管理),
+内部后端执行环境(backend_runtime)分三类:
 
 ```
-┌─────────────────────────────────────────────┐
-│              外部 Agent 请求                  │
-└──────────────────┬──────────────────────────┘
-                   │
-                   ▼
-┌─────────────────────────────────────────────┐
-│            注册层 (Registry)                  │
-│  - 工具分类注册                               │
-│  - 统一接口封装(输入/输出规范)               │
-│  - 工具元信息描述(用法、参数、返回值)        │
-└──────────────────┬──────────────────────────┘
-                   │
-                   ▼
-┌─────────────────────────────────────────────┐
-│     路由层 (Router) — 兼任对外网关            │
-│  - 对外:FastAPI (HTTP+SSE) / MCP 接口       │
-│  - 请求解析与工具匹配                         │
-│  - 冷热调度(按需唤醒/空闲休眠)              │
-│  - 环境感知(工具在哪、怎么启动、怎么对接)    │
-│  - 参数转换与传递                             │
-│  - 中间件:鉴权 / 结果缓存 / 调用计量        │
-└──────────────────┬──────────────────────────┘
-                   │
-                   ▼
-┌─────────────────────────────────────────────┐
-│            环境层 (Runtime)                   │
-│  - 工具物理存储与管理                         │
-│  - 环境隔离(本地进程 / Docker 容器)         │
-│  - 生命周期管理(启动/停止/健康检查)          │
-│  - 资源配额(CPU/内存/显存限制)               │
-└─────────────────────────────────────────────┘
+外部调用者(其他 Agent / HTTP 客户端)
+  │
+  ▼
+统一调用层(本地 Python + FastAPI HTTP 接口)
+  │
+  ├─ backend_runtime: local   → 本地 Python 运行时(纯 uv 子进程)
+  ├─ backend_runtime: docker  → Docker 容器运行时
+  └─ backend_runtime: remote  → 远程 API / 云端服务
 ```
 
-## 5. 接口设计
+### 2.2 消息流
 
-### 5.1 对外接口(路由层直接暴露)
-
-路由层同时承担网关职责,对外仅暴露两个固定端口:
-
-| 协议                         | 端口 | 用途                                          |
-| ---------------------------- | ---- | --------------------------------------------- |
-| FastAPI (HTTP)               | 8001 | RESTful 接口(含 SSE 流式推送)               |
-| MCP (Model Context Protocol) | 8001 | 标准 MCP Server,供支持 MCP 的 Agent 直接对接 |
+```
+外部 Agent
+  │
+  ├─ IM 消息 ──→ IMClient ──→ ChatWindow(chat_id) ──→ SessionManager ──→ ServiceAgent
+  │                                                                          │
+  ├─ HTTP /chat ─────────────────────────────────────→ SessionManager ──→ ServiceAgent
+  │                                                                          │
+  │                                                    需要创建工具时:       │
+  │                                                    submit_task() ────→ RouterAgent
+  │                                                                          │
+  │                                                                    CodingAgent
+  │                                                                          │
+  └─ HTTP /run_tool ──→ Dispatcher ──→ 工具进程 ←── 注册完成 ←──────────────┘
+```
 
-不单独开 WebSocket 端口。流式返回用 SSE,异步任务用回调/轮询,理由:
+### 2.3 工具组(Tool Group)
 
-- SSE 基于普通 HTTP,单向推流,客户端实现简单,出问题好调试
-- 外部 Agent 多为短生命周期进程,维护 WebSocket 长连接反而是负担
-- 如果未来有明确的多事件长连接需求,再加 WebSocket 也不迟
+需要固定配合使用的工具组成工具组,定义在 `data/groups.json`。
+例如 RunComfy 生命周期管理组:launch → run → stop。
 
-#### FastAPI 路由
+## 3. 三层架构
 
 ```
-GET  /tools                     # 获取工具目录(按类别)
-GET  /tools/{tool_id}/schema    # 获取单个工具的输入输出规范
-POST /tools/{tool_id}/invoke    # 调用工具(Accept: text/event-stream 时返回 SSE)
-POST /tools/request             # 提交新工具需求,返回 task_id
-GET  /tasks/{task_id}/status    # 异步任务状态轮询
-GET  /health                    # 服务健康检查
-GET  /openapi.json              # OpenAPI 定义自动导出
-```
-
-#### 异步任务(新工具需求)
+外部 Agent 请求
+       │
+       ▼
+┌─────────────────────────────────────┐
+│        注册层 (Registry)             │
+│  - 工具元数据 CRUD (registry.json)   │
+│  - 工具组管理 (groups.json)          │
+│  - 按类别/关键字/后端运行时搜索      │
+└──────────────┬──────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────┐
+│   路由层 (Router) — 兼任对外网关     │
+│  - FastAPI 对外接口                  │
+│  - 来源管理 (SourceStore)            │
+│  - 工具启停 (ToolStatusManager)      │
+│  - 请求分发 (Dispatcher)             │
+│  - CodingAgent 调度                  │
+│  - 全局状态监控                      │
+└──────────────┬──────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────┐
+│        运行层 (Runtime)              │
+│  - LocalRunner (uv 本地进程)         │
+│  - DockerRunner (Docker 容器)        │
+│  - 远程 API 直接转发                 │
+└─────────────────────────────────────┘
+```
+
+## 4. 对外接口
+
+仅暴露 4 个 HTTP 接口:
+
+| 接口 | 方法 | 说明 |
+|------|------|------|
+| `/health` | GET | 健康检查 |
+| `/tools` | GET | 完整工具表(含后端分类、工具组、运行状态) |
+| `/run_tool` | POST | 调用工具 `{tool_id, params}` |
+| `/chat` | POST | 与 ServiceAgent 对话 `{message, chat_id}` |
+
+### 4.1 /tools 响应示例
 
 ```json
-// POST /tools/request
 {
-  "description": "需要一个图片压缩工具",
-  "callback_url": "http://caller-agent/callback"   // 可选
-}
-
-// 响应
-{
-  "task_id": "uuid",
-  "status": "pending"
+  "backend_runtimes": [
+    {"backend_runtime": "local", "name": "本地 Python 运行时", "tool_count": 5},
+    {"backend_runtime": "docker", "name": "Docker 容器运行时", "tool_count": 0},
+    {"backend_runtime": "remote", "name": "远程 API / 云端服务", "tool_count": 0}
+  ],
+  "groups": [
+    {
+      "group_id": "runcomfy_lifecycle",
+      "name": "RunComfy 生命周期管理",
+      "tool_ids": ["launch_comfy_env", "runcomfy_workflow_executor", "runcomfy_stop_env"],
+      "usage_order": ["launch_comfy_env", "runcomfy_workflow_executor", "runcomfy_stop_env"]
+    }
+  ],
+  "tools": [
+    {
+      "tool_id": "image_stitcher",
+      "name": "图片拼接工具",
+      "category": "cv",
+      "backend_runtime": "local",
+      "group_ids": [],
+      "state": "stopped"
+    }
+  ]
 }
-
-// 任务完成后:
-// 方式 A:tool_agent POST callback_url 推送结果
-// 方式 B:外部 Agent 轮询 GET /tasks/{task_id}/status
 ```
 
-#### MCP 接口
-
-以标准 MCP Tool 协议暴露,每个注册工具自动映射为一个 MCP Tool,外部 Agent 可通过 `tools/list` 和 `tools/call` 直接使用。
-
-### 5.2 内部接口(按环境分层)
-
-uv 本地工具各自独立 venv,通过子进程 + stdio JSON 通信(零网络开销,保持环境隔离);Docker 工具通过端口映射 HTTP 通信。
-
-```
-                    外部 Agent
-                        │
-              ┌─────────┴─────────┐
-              ▼                   ▼
-        :8001 FastAPI        :8001 MCP
-        (HTTP + SSE)
-              └─────────┬─────────┘
-                        │ 路由层按 tool_id 分发
-              ┌─────────┴─────────┐
-              ▼                   ▼
-     ┌── uv 本地工具 ──┐   ┌── Docker 工具 ──┐
-     │  子进程 + stdio  │   │  HTTP 端口映射   │
-     │  各自独立 venv   │   │  :9001  :9002   │
-     │  tool_a  tool_c  │   │  tool_b  tool_d │
-     └─────────────────┘   └─────────────────┘
-```
-
-#### uv 本地工具接口规范(子进程 + stdio JSON)
-
-每个 uv 工具必须实现统一的 Python 入口,通过 stdin 接收请求、stdout 输出结果:
-
-```python
-# tools/local/tool_a/main.py
-import sys, json
-
-class Tool:
-    name: str = "tool_a"
-    description: str = "工具描述"
+### 4.2 /chat 请求/响应
 
-    def schema(self) -> dict:
-        return {"input": {...}, "output": {...}}
-
-    def run(self, params: dict, stream: bool = False) -> dict:
-        return {"result": ...}
-
-    def health(self) -> bool:
-        return True
-
-if __name__ == "__main__":
-    tool = Tool()
-    request = json.loads(sys.stdin.read())
-    action = request.get("action", "run")
-
-    if action == "schema":
-        output = tool.schema()
-    elif action == "health":
-        output = {"healthy": tool.health()}
-    else:
-        output = tool.run(request.get("params", {}), request.get("stream", False))
+```json
+// 请求
+{"message": "有哪些图片处理工具?", "chat_id": "user_A_window_1"}
 
-    json.dump(output, sys.stdout)
+// 响应
+{"response": "当前有 image_stitcher...", "chat_id": "user_A_window_1"}
 ```
 
-路由层调用方式:
-
-```python
-import subprocess, json
-
-def call_local_tool(tool_id: str, request: dict) -> dict:
-    result = subprocess.run(
-        ["uv", "run", "--directory", f"tools/local/{tool_id}", "python", "main.py"],
-        input=json.dumps(request),
-        capture_output=True, text=True
-    )
-    return json.loads(result.stdout)
-```
+## 5. 内部通信
 
-- 每次调用启动独立子进程,天然隔离
-- 无需管理端口或长驻进程
-- 冷启动有一定开销,高频工具可考虑进程池复用
+### 5.1 来源类型(SourceType)
 
-#### Docker 工具接口规范(HTTP)
+| SourceType | 说明                    | 通信方式                            |
+| ---------- | ----------------------- | ----------------------------------- |
+| `local`    | 本地 uv 项目            | `uv run` 启动 HTTP 服务,httpx 调用 |
+| `docker`   | Docker 容器             | 端口映射 HTTP                       |
+| `remote`   | 远程 API / 云端服务     | 直接转发 HTTP                       |
 
-Docker 工具因环境隔离,必须在容器内启动 HTTP 服务:
+### 5.2 请求分发流程
 
 ```
-POST /run          # 执行工具主逻辑(支持流式 SSE)
-GET  /health       # 健康检查
-GET  /schema       # 返回自身输入输出 schema
-```
-
-请求/响应格式:
-
-```json
-// POST /run 请求
-{
-  "request_id": "uuid",
-  "params": { },
-  "stream": false
-}
-
-// POST /run 响应(非流式)
-{
-  "request_id": "uuid",
-  "status": "success | error",
-  "result": { },
-  "error": "错误信息(可选)"
-}
-
-// POST /run 响应(流式,Content-Type: text/event-stream)
-data: {"request_id": "uuid", "chunk": "部分结果...", "done": false}
-data: {"request_id": "uuid", "chunk": "最终结果", "done": true}
+POST /run_tool {tool_id, params}
+       │
+       ▼
+ToolStatusManager.get_status(tool_id)
+       │ 未运行?
+       ▼
+ToolStatusManager.start_tool(tool_id)
+  ├─ local:  uv run python main.py --port {free_port}
+  ├─ docker: 检查容器端口映射
+  └─ remote: 直接标记 running
+       │
+       ▼
+Dispatcher.dispatch(tool_id, params)
+  → httpx.post("http://127.0.0.1:{port}{endpoint_path}", json=params)
+       │
+       ▼
+返回结果
 ```
 
-#### 路由层中间件
-
-| 中间件   | 作用                                                     |
-| -------- | -------------------------------------------------------- |
-| 结果缓存 | 相同 tool_id + params 的重复调用直接返回缓存,可配置 TTL |
-| 调用计量 | 记录每个工具的调用频次、耗时、错误率,供调度决策使用     |
-
-### 5.3 环境间通信
-
-| 场景                   | 通信方式                  | 说明                                                |
-| ---------------------- | ------------------------- | --------------------------------------------------- |
-| uv 工具 ↔ 路由层       | 子进程 stdio JSON         | `uv run` 启动独立 venv,stdin/stdout 传参,天然隔离 |
-| Docker(conda) ↔ 路由层 | localhost:{映射端口} HTTP | 容器端口映射到宿主机                                |
-| Docker ↔ Docker        | docker network            | 同一 bridge 内可直接通信                            |
-| uv ↔ Docker(conda)     | 经路由层中转              | 不直接通信,统一走路由层                            |
-
-### 5.4 端口分配与服务发现
+### 5.3 端口分配
 
 ```
 对外(固定):
-  8001        - FastAPI(HTTP + SSE)
-  8001        - MCP Server
+  8001        - FastAPI
 
-对内:
-  uv 本地工具  - 无需端口,子进程 stdio 通信
-  Docker 工具  - 9001+ 端口映射,按注册顺序递增
-  9000         - 路由层管理端口
+对内(动态):
+  uv 本地工具  - 随机空闲端口(socket bind 0)
+  Docker 工具  - 容器内部端口映射
+  远程工具     - 无需端口,直接转发
 ```
 
-- 对外只有 8001/8001,外部 Agent 永远只访问这两个入口
-- uv 工具零端口占用,路由层 import 后直接调用
-- Docker 工具启动时分配端口,写入注册表,停止后回收
-
-#### 备选:Unix Domain Sockets (UDS)
+## 6. Agent 角色详解
 
-对于同机通信场景,可选用 UDS 替代 TCP 端口:
+### 6.1 RouterAgent
 
-- 路径规范:`/tmp/tool_agent/sockets/{tool_id}.sock`
-- 优势:不占端口号、无网络协议栈开销、速度更快
-- 适用于 uv 本地工具;Docker 工具仍需端口映射
+常驻进程,main 启动时即运行。
 
-#### 注册表增强
+**职责**:
+- 管理工具注册表(Registry)和状态表(ToolStatusManager)
+- 调度 CodingAgent 执行工具创建任务
+- 监控全局状态(工具、CodingAgent 任务、ServiceAgent 窗口)
+- 对外暴露 FastAPI 接口
 
-`registry.json` 中每个工具条目增加调度相关字段:
+**全局状态查询** (`router.get_system_status()`)
 
 ```json
 {
-  "tool_id": "tool_a",
-  "port": 9001,
-  "socket": "/tmp/tool_agent/sockets/tool_a.sock",
-  "last_used_time": "2026-03-19T10:00:00Z",
-  "call_count": 42,
-  "avg_latency_ms": 120,
-  "state": "running | sleeping | stopped"
+  "tools": {"total": 5, "active": 5},
+  "coding_agent": {
+    "running_count": 1,
+    "running_task_ids": ["create_a1b2c3d4"],
+    "queued_count": 0,
+    "recent_tasks": [
+      {"task_id": "create_a1b2c3d4", "status": "pending", "task_spec": "..."}
+    ]
+  },
+  "service_agent": {
+    "total_windows": 3,
+    "im_connected": true,
+    "windows": [
+      {"chat_id": "user_A_w1", "sdk_session_id": "...", "has_im_window": true}
+    ]
+  }
 }
 ```
 
-### 5.5 部署设计
+### 6.2 ServiceAgent
 
-#### 主服务(tool_agent 本体)
+按需创建,一个实例管理多个 chat_id 窗口。
 
-- 运行环境:uv 管理的 Python 虚拟环境
-- 启动方式:`uv run python -m tool_agent`
-- 包含:路由层(对外接口 + 内部分发)+ MCP Server + Agent 逻辑
+**职责**:
+- 只读查询工具表、工具组、后端运行时、工具状态
+- 与外部 Agent 对话,解答工具使用问题
+- 撰写任务书提交给 RouterAgent
 
-#### uv 本地工具
+**工具列表**(6 个):
 
-- 每个工具一个独立目录,各自 `pyproject.toml`
-- 由路由层通过 `uv run` 启动子进程
-- 适用于轻量工具、纯 Python 工具
+| 工具 | 权限 | 说明 |
+|------|------|------|
+| `list_tools` | 读 | 列出所有工具 |
+| `get_tool_details` | 读 | 查看工具详情(参数、Schema) |
+| `list_groups` | 读 | 列出工具组及使用顺序 |
+| `list_backend_runtimes` | 读 | 列出后端执行环境类型 |
+| `check_task_status` | 读 | 查询任务进度 |
+| `submit_task` | 写 | 撰写任务书提交给 RouterAgent |
 
-#### Docker 工具(conda 环境)
+**会话管理**:
+- SessionManager 按 chat_id 管理 Claude SDK session
+- 每个 chat_id 独立的对话记忆(通过 SDK resume 机制)
+- 同一个 ServiceAgent 实例并发处理多个窗口
 
-- 每个工具一个 Dockerfile,内部使用 conda 管理依赖
-- 由路由层通过 Docker SDK 启动/停止容器
-- 适用于:重依赖工具、GPU 工具、需要特殊系统库的工具
+### 6.3 CodingAgent
 
-```
-tools/
-├── local/                    # uv 本地工具(生产)
-│   ├── tool_a/
-│   │   ├── pyproject.toml
-│   │   ├── main.py           # 实现 /run /health /schema
-│   │   └── ...
-│   └── tool_b/
-├── docker/                   # Docker 工具(生产)
-│   ├── tool_c/
-│   │   ├── Dockerfile
-│   │   ├── environment.yml   # conda 环境定义
-│   │   ├── main.py
-│   │   └── ...
-│   └── tool_d/
-├── staging/                  # 预发环境(Agent 新写的工具先放这里)
-│   └── tool_new/
-│       ├── main.py
-│       └── test_tool.py      # Agent 自动生成的测试脚本
-└── registry.json             # 工具注册表
-```
+按需唤醒,接收 RouterAgent 的任务书。
 
-### 5.6 冷热调度机制
+**工具列表**(10 个):
 
-路由层根据工具类型和使用频率,自动管理工具进程的生命周期:
+| 分类   | 工具                   | 说明                              |
+| ------ | ---------------------- | --------------------------------- |
+| Docker | `create_docker_env`    | 创建容器(端口映射/挂载/GPU)     |
+| Docker | `run_in_docker`        | 容器内执行命令(前台/后台)       |
+| Docker | `rebuild_docker_ports` | 重建容器加端口映射                |
+| Docker | `destroy_docker_env`   | 销毁容器                          |
+| uv     | `create_uv_project`    | 创建 uv 项目                      |
+| uv     | `run_in_uv`            | 在 uv 环境中运行命令              |
+| uv     | `uv_add_dependency`    | 添加依赖                          |
+| 文件   | `write_file`           | 写文件(overwrite/append)        |
+| 文件   | `read_file`            | 读文件                            |
+| 注册   | `register_tool`        | 注册工具到 Registry               |
 
-| 类型              | 策略        | 说明                                                       |
-| ----------------- | ----------- | ---------------------------------------------------------- |
-| 冷工具(uv 本地) | 按需唤醒    | 收到请求时 `uv run` 启动,空闲超时后自动杀掉进程释放内存   |
-| 热工具(Docker)  | 常驻 + 置换 | 高频工具保持容器运行;显存/内存满时按 LRU 策略置换低频容器 |
-| API 代理          | 无状态      | 无需管理进程,直接转发                                     |
+## 7. IM Client 接入
 
-调度参数(可配置):
+### 7.1 架构
 
-```json
-{
-  "cold_tool_idle_timeout_s": 300,
-  "hot_tool_max_containers": 5,
-  "eviction_policy": "lru"
-}
 ```
-
-### 5.7 Staging 预发环境
-
-Agent 自主编写的新工具不直接进入生产目录,需经过预发验证:
-
-```
-Agent 编写代码 → staging/ 目录
-       ↓
-路由层分配临时端口启动
-       ↓
-Agent 编写并执行测试脚本
-       ↓
-  ┌─ 通过 → Promote 到 local/ 或 docker/,注册到 registry
-  └─ 失败 → Agent 修复后重试,或标记为 failed 等待人工介入
-```
-
-安全审计:Agent 生成的代码在 promote 前需通过基础安全检查(禁止危险系统调用如 `rm -rf`、`os.system` 等)。
-
-### 5.8 逆向 API 自修复
-
-当逆向接入的工具出现故障时,触发自动修复闭环:
-
-```
-工具返回 error(403/签名失效/接口变更)
-       ↓
-路由层标记工具状态为 degraded
-       ↓
-通知 Tool Agent 介入
-       ↓
-  ┌─ 尝试 Browser-Use 重新抓包更新接口参数
-  ├─ 尝试切换到备用 API 策略
-  └─ 均失败 → 标记 inactive,通知调用方降级
+IM Server (WebSocket, port 8000)
+    ↕
+IMClient (单 WebSocket 连接,contact_id="tool_agent")
+    ├─ ChatWindow(chat_id_1) ──→ SessionManager ──→ ServiceAgent session_1
+    ├─ ChatWindow(chat_id_2) ──→ SessionManager ──→ ServiceAgent session_2
+    └─ ChatWindow(chat_id_3) ──→ SessionManager ──→ ServiceAgent session_3
 ```
 
-对外部调用者完全透明:调用方只看到短暂延迟或降级通知,无需感知内部策略切换。
+### 7.2 消息路由
 
-## 6. 双 Agent 架构
+- 外部 Agent 发送消息时指定 `receiver_chat_id`,定向到对应窗口
+- 未指定 `receiver_chat_id` 时广播到所有窗口
+- 回复时带上 `receiver_chat_id = sender_chat_id`,消息回到对方窗口
 
-### 6.1 Router Agent(路由层维护者)
-
-常驻进程,统领全局,是系统的"大脑"。
-
-| 能力         | 说明                                                  |
-| ------------ | ----------------------------------------------------- |
-| 对外接口管理 | 维护 FastAPI / MCP / WebSocket 服务,处理外部请求     |
-| 请求路由     | 解析请求,匹配工具,分发到 uv 子进程或 Docker 容器    |
-| 冷热调度     | 按需唤醒冷工具、LRU 置换热工具、管理进程/容器生命周期 |
-| 健康监控     | 定期检查工具状态,发现异常时通知 Tool Agent 修复      |
-| 中间件管理   | 鉴权、结果缓存、调用计量、流量控制                    |
-| 注册表维护   | 接收 Tool Agent 的 promote 通知,更新路由规则         |
-
-### 6.2 Tool Agent(工具库维护者)
-
-按需唤醒或常驻,负责工具的"生产"和"维修"。
-
-| 能力          | 说明                                                               |
-| ------------- | ------------------------------------------------------------------ |
-| 工程编码      | 拥有独立工作区(staging/),自主编写工具代码、测试脚本、接口适配器 |
-| 代码审计      | 对生成代码进行安全检查(禁止危险系统调用、SQL 注入等)             |
-| 财务能力      | 自动注册第三方账号、管理 API Key、自动充值/订阅付费 API            |
-| 决策推理      | 分析工具需求,选择最优获取策略(API/部署/逆向/浏览器)             |
-| Browser-Use   | 浏览器自动化,用于网页操作、信息采集、账号注册、逆向抓包           |
-| 子 Agent 调度 | 将子任务分发给专用子 Agent 并行处理                                |
-| 自修复        | 接收 Router Agent 的异常通知,自动修复失效工具                     |
-| 知识维护      | 实时总结工具库结构变更,维护各层元信息                             |
-
-### 6.3 双 Agent 协作流程
-
-```
-外部 Agent 请求新工具
-       │
-       ▼
-Router Agent 收到请求,查注册表无此工具
-       │
-       ▼ 通知
-Tool Agent 介入 → 决策获取策略 → 编码/部署/购买
-       │
-       ▼
-staging 验证通过 → promote
-       │
-       ▼ 通知
-Router Agent 更新注册表 + 路由规则
-       │
-       ▼
-Router Agent 响应外部 Agent:工具已就绪
-```
+### 7.3 并发模型
 
-```
-Router Agent 健康检查发现工具异常
-       │
-       ▼ 通知
-Tool Agent 介入 → 自修复(重新抓包/切换策略)
-       │
-       ▼ 修复完成通知
-Router Agent 更新工具状态为 active
-```
+- 一个 IMClient 实例管理多个 ChatWindow
+- 每个 ChatWindow 有独立的 `pending.json` / `chatbox.jsonl`
+- SessionManager 收到通知后,按 chat_id 路由到对应 ServiceAgent session
+- 同一个 ServiceAgent 实例并发处理,Claude SDK session 隔离对话记忆
 
-### 6.4 财务管理(Tool Agent)
+## 8. 数据模型
 
-Agent 在获取新工具时可能涉及付费操作,需要一套财务管控机制:
+### 8.1 ToolMeta(工具元数据)
 
 ```json
 {
-  "budget": {
-    "monthly_limit_usd": 100,
-    "single_tx_limit_usd": 20,
-    "require_approval_above_usd": 10,
-    "spent_this_month_usd": 0
-  },
-  "accounts": [
-    {
-      "provider": "openai",
-      "api_key": "sk-***",
-      "balance_usd": 50,
-      "auto_recharge": false
-    }
-  ]
+  "tool_id": "image_stitcher",
+  "name": "图片拼接工具",
+  "description": "将多张图片拼接成一张",
+  "category": "cv",
+  "backend_runtime": "local",
+  "group_ids": [],
+  "input_schema": {},
+  "output_schema": {},
+  "stream_support": false,
+  "status": "active"
 }
 ```
 
-- 低于单笔限额:Agent 自主完成注册/充值
-- 超过审批阈值:暂停操作,通知用户审批后继续
-- 所有支出记录到 `billing_log.json`,可追溯
+### 8.2 ToolSource(工具来源)
 
-### 6.5 独立编码工作区(Tool Agent)
-
-Agent 编写代码的完整闭环:
-
-```
-staging/
-├── {task_id}/              # 每个编码任务一个隔离目录
-│   ├── main.py             # Agent 编写的工具代码
-│   ├── test_main.py        # Agent 编写的测试脚本
-│   ├── pyproject.toml      # 依赖声明
-│   └── run_log.txt         # 测试运行日志
-```
-
-工作流程:
-
-```
-1. 创建任务目录 staging/{task_id}/
-2. Agent 编写代码 + 测试脚本
-3. 在隔离环境中 uv run pytest 执行测试
-4. 测试失败 → Agent 读取日志 → 修复 → 重新测试(最多 N 轮)
-5. 测试通过 → 安全审计 → Promote 到生产目录
+```json
+{
+  "type": "local",
+  "host_dir": "tools/local/image_stitcher",
+  "endpoint_path": "/",
+  "http_method": "POST",
+  "internal_port": 0
+}
 ```
 
-### 6.6 运行模式
-
-- Router Agent:常驻进程,始终在线,通过 WebSocket 保持长连接
-- Tool Agent:按需唤醒(收到新工具需求或异常通知时启动),也可常驻
-- 两者通过进程内消息队列通信,无需额外网络开销
-
-## 7. 数据模型(工具元信息)
+### 8.3 ToolRoute(运行状态)
 
 ```json
 {
-  "tool_id": "string",
-  "name": "工具名称",
-  "category": "分类标签",
-  "description": "功能描述",
-  "input_schema": {},
-  "output_schema": {},
-  "stream_support": false,
-  "runtime": {
-    "type": "local | docker | api | browser",
-    "entry": "启动入口",
-    "env": {},
-    "resource_limits": {
-      "cpu": "1.0",
-      "memory_mb": 512,
-      "gpu": false
-    }
-  },
-  "scheduling": {
-    "mode": "cold | hot | stateless",
-    "idle_timeout_s": 300,
-    "state": "running | sleeping | stopped | degraded"
-  },
-  "stats": {
-    "last_used_time": "ISO8601",
-    "call_count": 0,
-    "avg_latency_ms": 0,
-    "error_rate": 0.0
-  },
-  "fallback_strategy": ["api", "browser"],
-  "status": "active | inactive | staging | building"
+  "tool_id": "image_stitcher",
+  "sources": [{"type": "local", "host_dir": "..."}],
+  "active_source": 0,
+  "state": "running",
+  "pid": 12345,
+  "port": 52341
 }
 ```
 
-## 8. 通信协议
+### 8.4 BackendRuntime(后端执行环境枚举)
 
-### 8.1 对外协议(HTTP + SSE)
+| 值 | 说明 |
+|----|------|
+| `local` | 本地 Python 运行时(纯 uv 子进程) |
+| `docker` | Docker 容器运行时 |
+| `remote` | 远程 API / 云端服务 |
 
-```json
-// 同步调用 POST /tools/{tool_id}/invoke
-// 请求
-{
-  "params": { },
-  "stream": false
-}
+### 8.5 ToolGroup(工具组)
 
-// 响应(非流式)
+```json
 {
-  "status": "success | error",
-  "result": { }
+  "group_id": "runcomfy_lifecycle",
+  "name": "RunComfy 生命周期管理",
+  "description": "云端 ComfyUI 环境的完整生命周期",
+  "category": "remote",
+  "tool_ids": ["launch_comfy_env", "runcomfy_workflow_executor", "runcomfy_stop_env"],
+  "usage_order": ["launch_comfy_env", "runcomfy_workflow_executor", "runcomfy_stop_env"],
+  "usage_example": "先 launch 获取 server_id,再 run 执行 workflow,最后 stop 释放资源"
 }
-
-// 响应(流式,Accept: text/event-stream)
-data: {"chunk": "部分结果...", "done": false}
-data: {"chunk": "最终结果", "done": true}
 ```
 
-### 8.2 内部协议(双 Agent 消息总线)
-
-Router Agent 与 Tool Agent 通过 `asyncio.Queue` 通信,零网络开销:
+## 9. 目录结构
+
+```
+src/
+├── tool_agent/
+│   ├── __main__.py              # 入口:启动 Router + SessionManager + IM
+│   ├── config.py                # 全局配置
+│   ├── models.py                # 数据模型(ToolMeta, BackendRuntime 等)
+│   ├── router/
+│   │   ├── agent.py             # RouterAgent(常驻,调度 CodingAgent)
+│   │   ├── server.py            # FastAPI 接口定义
+│   │   ├── dispatcher.py        # 请求分发
+│   │   └── status.py            # 工具状态管理 + 来源存储
+│   ├── service/
+│   │   ├── agent.py             # ServiceAgent(只读客服 + 任务书提交)
+│   │   └── session.py           # SessionManager(chat_id 会话管理 + IM 接入)
+│   ├── registry/
+│   │   ├── registry.py          # 工具注册表
+│   │   └── groups.py            # 工具组管理
+│   ├── tool/
+│   │   └── agent.py             # CodingAgent(自主编码部署)
+│   └── runtime/
+│       └── local_runner.py      # 本地 uv 运行器
+├── im-client/                   # IM 通信客户端(独立模块)
+│   ├── client.py                # IMClient + ChatWindow
+│   ├── protocol.py              # 消息协议
+│   ├── notifier.py              # 通知接口
+│   └── tools.py                 # Agent 工具函数封装
+data/
+├── registry.json                # 工具注册表
+├── sources.json                 # 工具来源
+└── groups.json                  # 工具组配置
+```
+
+## 10. 启动流程
 
 ```python
-# 消息格式
-{
-  "type": "tool_request | tool_ready | tool_error | health_alert",
-  "payload": { }
-}
+async def main():
+    router = Router()                          # RouterAgent 初始化
+    session_mgr = SessionManager(router)       # 会话管理
+    router.set_session_manager(session_mgr)    # 双向引用
+    router.create_app(session_manager=session_mgr)
+
+    await session_mgr.start_im("tool_agent", "ws://localhost:8000")  # IM 连接(可选)
+    await router.start(port=8001)              # FastAPI 启动
 ```
 
-## 9. 项目代码结构
+启动命令:`uv run python -m tool_agent`
 
-```
-tool_agent/
-├── pyproject.toml                  # 主项目依赖(fastapi, uvicorn, docker, mcp-sdk 等)
-├── README.md
-│
-├── src/
-│   └── tool_agent/
-│       ├── __init__.py
-│       ├── __main__.py             # 入口:uv run python -m tool_agent
-│       ├── config.py               # 全局配置(端口、调度参数、预算等)
-│       │
-│       ├── router/                 # Router Agent — 路由层
-│       │   ├── __init__.py
-│       │   ├── agent.py            # Router Agent 主逻辑(调度决策、健康监控)
-│       │   ├── server.py           # FastAPI 应用定义 + 路由注册 + SSE 流式
-│       │   ├── mcp_server.py       # MCP Server 适配层
-│       │   ├── dispatcher.py       # 请求分发:按 tool_id 路由到 uv 子进程 / Docker
-│       │   ├── scheduler.py        # 冷热调度(唤醒/休眠/LRU 置换)
-│       │   ├── middleware/
-│       │   │   ├── __init__.py
-│       │   │   ├── auth.py         # 鉴权
-│       │   │   ├── cache.py        # 结果缓存
-│       │   │   └── metrics.py      # 调用计量
-│       │   └── health.py           # 工具健康检查
-│       │
-│       ├── tool/                   # Tool Agent — 工具库维护
-│       │   ├── __init__.py
-│       │   ├── agent.py            # Tool Agent 主逻辑(决策、编排)
-│       │   ├── builder.py          # 工具编码:生成代码 + 测试脚本
-│       │   ├── deployer.py         # 工具部署:uv init / docker build
-│       │   ├── promoter.py         # staging → 生产目录 promote 流程
-│       │   ├── auditor.py          # 代码安全审计
-│       │   ├── repairer.py         # 逆向 API 自修复
-│       │   ├── browser.py          # Browser-Use 能力封装
-│       │   └── finance.py          # 财务管理(账号注册/充值/预算)
-│       │
-│       ├── registry/               # 注册层
-│       │   ├── __init__.py
-│       │   ├── registry.py         # 注册表 CRUD(读写 registry.json)
-│       │   ├── catalog.py          # 工具目录(按类别组织、搜索)
-│       │   └── schema.py           # 工具元信息 schema 定义(Pydantic models)
-│       │
-│       ├── runtime/                # 环境层
-│       │   ├── __init__.py
-│       │   ├── local_runner.py     # uv 子进程调用(subprocess + stdio JSON)
-│       │   ├── docker_runner.py    # Docker 容器管理(Docker SDK)
-│       │   ├── api_proxy.py        # 外部 API 代理转发
-│       │   └── resource.py         # 资源配额管理(CPU/内存/显存)
-│       │
-│       ├── messaging.py            # 双 Agent 内部消息队列(asyncio.Queue)
-│       └── models.py               # 公共数据模型(请求/响应/工具元信息)
-│
-├── tools/                          # 工具库(与主项目代码分离)
-│   ├── local/                      # uv 本地工具(生产)
-│   │   └── example_tool/
-│   │       ├── pyproject.toml
-│   │       └── main.py
-│   ├── docker/                     # Docker 工具(生产)
-│   │   └── example_gpu_tool/
-│   │       ├── Dockerfile
-│   │       ├── environment.yml
-│   │       └── main.py
-│   └── staging/                    # 预发环境
-│       └── .gitkeep
-│
-├── data/                           # 运行时数据
-│   ├── registry.json               # 工具注册表
-│   ├── billing_log.json            # 财务支出记录
-│   └── config.json                 # 运行时配置覆盖
-│
-└── tests/
-    ├── test_dispatcher.py
-    ├── test_registry.py
-    ├── test_scheduler.py
-    └── test_local_runner.py
-```
+## 11. KnowHub 工具表集成
 
-### 9.1 模块职责映射
+### 11.1 双向索引架构
 
-| 目录                       | 归属              | 职责                                          |
-| -------------------------- | ----------------- | --------------------------------------------- |
-| `src/tool_agent/router/`   | Router Agent      | 对外接口、请求分发、调度、中间件、健康监控    |
-| `src/tool_agent/tool/`     | Tool Agent        | 工具编码、部署、审计、修复、财务、browser-use |
-| `src/tool_agent/registry/` | 共享              | 注册表读写,双 Agent 都会访问                 |
-| `src/tool_agent/runtime/`  | Router Agent 调用 | 实际执行工具的运行时(子进程/Docker/API代理) |
-| `tools/`                   | Tool Agent 维护   | 工具代码本身,与主项目解耦                    |
-| `data/`                    | 共享              | 运行时状态数据                                |
+Tool Agent 与 KnowHub 工具表通过双向索引互相关联:
 
-### 9.2 关键入口
+```
+Tool Agent Registry              KnowHub 工具表
+┌─────────────────────┐         ┌──────────────────────────┐
+│ tool_id: launch_env │◄────────│ id: tools/image_gen/     │
+│ tool_slug_ids:      │         │     comfyui              │
+│   ["comfyui"]       │────────►│ toolhub_items:           │
+│                     │         │   [{launch_env: "..."},  │
+│ group_ids:          │         │    {runcomfy_group: ...}]│
+│   ["runcomfy_group"]│         └──────────────────────────┘
+└─────────────────────┘
+```
 
-```python
-# src/tool_agent/__main__.py
-import asyncio
-from tool_agent.router.agent import RouterAgent
-from tool_agent.tool.agent import ToolAgent
-from tool_agent.messaging import MessageBus
+**字段说明**:
+- `tool_slug_ids: list[str]` - Tool Agent 工具关联的 KnowHub tool_slug 列表
+- `toolhub_items: list[dict]` - KnowHub 工具关联的 Tool Agent 工具/组列表
 
-async def main():
-    bus = MessageBus()
-    router_agent = RouterAgent(bus)
-    tool_agent = ToolAgent(bus)
+### 11.2 智能匹配机制
 
-    await asyncio.gather(
-        router_agent.start(),   # 启动 FastAPI/MCP/WS + 调度循环
-        tool_agent.start(),     # 监听消息队列,按需处理任务
-    )
+Router Agent 在 CodingAgent 注册完工具后,自动执行智能匹配:
 
-if __name__ == "__main__":
-    asyncio.run(main())
-```
+**匹配规则**:
+1. 工具名称/ID 包含 KnowHub 的 slug 或 title
+2. 工具描述包含 KnowHub 描述的关键词
+3. 分类匹配
 
-## 10. 里程碑计划
-
-| 阶段    | 内容         | 说明                                                 |
-| ------- | ------------ | ---------------------------------------------------- |
-| Phase 1 | 基础框架     | 路由层(FastAPI + MCP + SSE)+ 注册层 + 工具目录查询 |
-| Phase 2 | 路由与执行   | 路由层实现 + 冷热调度 + 本地工具调用链路打通         |
-| Phase 3 | 环境层       | Docker 隔离 + 资源配额 + 工具生命周期管理            |
-| Phase 4 | Staging 闭环 | 预发环境 + 代码审计 + 自动测试 + Promote 流程        |
-| Phase 5 | Agent 智能   | 新工具自动获取(API/部署/逆向/browser-use)+ 自修复  |
-| Phase 6 | 自维护       | 知识总结、健康检查、工具自动更新、调用缓存           |
-
-## 11. 风险与应对
-
-| 风险                | 影响       | 应对措施                                                   |
-| ------------------- | ---------- | ---------------------------------------------------------- |
-| 逆向 API 稳定性差   | 工具不可用 | 自修复闭环:Browser-Use 重新抓包 → 备用策略切换 → 降级通知 |
-| Docker 环境资源占用 | 本地性能   | 冷热调度 + 资源配额(CPU/内存/显存上限)                   |
-| 工具间依赖冲突      | 环境污染   | 严格隔离,每工具独立环境                                   |
-| 外部 Agent 恶意调用 | 安全风险   | 鉴权机制 + 调用频率限制                                    |
-| Agent 生成危险代码  | 系统安全   | staging 预发验证 + 代码安全审计                            |
-| 端口碎片化          | 管理复杂   | UDS 备选方案 + 端口回收 + LRU 淘汰                         |
+**自动更新流程**:
+```python
+# router/agent.py:_sync_knowhub_after_register()
+1. 遍历所有 registry 工具
+2. 调用 _match_knowhub_tools() 智能匹配
+3. 更新 registry.tool_slug_ids
+4. 收集工具和组,更新 KnowHub.toolhub_items
+5. 点亮 KnowHub 工具 status="已接入"
+```
+
+**相关文件**:
+- `src/tool_agent/models.py:58` - ToolMeta.tool_slug_ids 字段定义
+- `src/tool_agent/router/agent.py:115-217` - 智能匹配和同步逻辑
+- `src/tool_agent/tool_table.py` - KnowHub API 客户端
+- `docs/tool_table/tool-table-integration.md` - 详细集成文档
+
+## 12. 里程碑
+
+| 阶段    | 内容                                           | 状态      |
+| ------- | ---------------------------------------------- | --------- |
+| Phase 1 | Router + Registry + FastAPI + CodingAgent      | ✅ 已完成 |
+| Phase 2 | LocalRunner + DockerRunner + 请求分发          | ✅ 已完成 |
+| Phase 3 | 工具分类 + 工具组 + BackendRuntime 语义统一    | ✅ 已完成 |
+| Phase 4 | ServiceAgent + SessionManager + IM Client 接入 | ✅ 已完成 |
+| Phase 5 | KnowHub 工具表集成 + 智能匹配                  | ✅ 已完成 |
+| Phase 6 | 冷热调度集成 + 健康检查集成                    | 🔲 待集成 |
+| Phase 7 | 中间件集成(鉴权/缓存/计量)                   | 🔲 待集成 |
+| Phase 8 | 自修复 + Browser-Use + 财务管理                | 🔲 待实现 |

+ 491 - 234
docs/internal_api.md

@@ -4,15 +4,14 @@
 
 1. [数据模型](#1-数据模型)
 2. [工具注册表格式](#2-工具注册表格式-registryjson)
-3. [容器状态表格式](#3-容器状态表格式-containersjson)
-4. [任务书格式](#4-任务书格式-task_spec)
-5. [内部消息格式](#5-内部消息格式-agentmessage)
-6. [对外 HTTP 接口](#6-对外-http-接口)
-7. [Coding Agent 工具接口](#7-coding-agent-工具接口)
-8. [资源监控格式](#8-资源监控格式)
+3. [来源存储格式](#3-来源存储格式-sourcesjson)
+4. [容器状态表格式](#4-容器状态表格式-containersjson)
+5. [任务书格式](#5-任务书格式-task_spec)
+6. [内部消息格式](#6-内部消息格式-agentmessage)
+7. [对外 HTTP 接口](#7-对外-http-接口)
+8. [Coding Agent 工具接口](#8-coding-agent-工具接口)
 9. [配置参数](#9-配置参数)
-10. [工作日志格式](#10-工作日志格式)
-11. [财务记录格式](#11-财务记录格式)
+10. [Router 工具匹配与对接](#10-router-工具匹配与对接)
 
 ---
 
@@ -20,14 +19,16 @@
 
 ### 1.1 枚举类型
 
+> **架构约束**:所有工具对外统一为本地 Python/FastAPI 调用层(uv 管理)。
+> `SourceType` / `BackendRuntime` 表示工具**内部的后端执行环境**,不是调用方式。
+
 | 枚举 | 值 | 说明 |
 |------|-----|------|
-| RuntimeType | `local`, `docker`, `api`, `browser` | 运行时类型 |
-| SchedulingMode | `cold`, `hot`, `stateless` | 调度模式 |
-| ToolState | `running`, `sleeping`, `stopped`, `degraded` | 工具状态 |
-| ToolStatus | `active`, `inactive`, `staging`, `building` | 工具生命周期 |
+| ToolStatus | `active`, `inactive`, `staging`, `building` | 工具生命周期状态 |
 | MessageType | `tool_request`, `tool_ready`, `tool_error`, `health_alert` | 内部消息类型 |
 | ContainerStatus | `running`, `destroyed` | 容器状态 |
+| SourceType | `local`, `docker`, `remote` | 工具来源类型(后端执行环境) |
+| ProcessState | `stopped`, `starting`, `running`, `error` | 进程运行状态 |
 
 ### 1.2 ToolMeta(工具元信息)
 
@@ -53,51 +54,64 @@
     }
   },
   "stream_support": false,
-  "runtime": {
-    "type": "docker",
-    "entry": "",
-    "container_id": "25e884ca6cec...",
-    "host_dir": "C:/Users/user/staging/image_tool",
-    "container_dir": "/app",
-    "host_port": 9001,
-    "internal_port": 8080,
-    "endpoint_path": "/api/compress",
-    "http_method": "POST",
-    "env": {},
-    "resource_limits": {"cpu": "1.0", "memory_mb": 512, "gpu": false}
-  },
-  "scheduling": {
-    "mode": "hot",
-    "idle_timeout_s": 300,
-    "state": "running"
-  },
-  "stats": {
-    "last_used_time": "2026-03-20T10:30:00Z",
-    "call_count": 42,
-    "avg_latency_ms": 125.5,
-    "error_rate": 0.02
-  },
-  "fallback_strategy": [],
   "status": "active"
 }
 ```
 
-### 1.3 RuntimeInfo(运行时信息
+### 1.3 ToolSource(工具来源)
 
-| 字段 | 类型 | 默认值 | 说明 |
-|------|------|--------|------|
-| `type` | RuntimeType | `"local"` | 运行环境类型 |
-| `container_id` | str | `""` | Docker 容器 ID |
-| `host_dir` | str | `""` | 宿主机工作目录 |
-| `container_dir` | str | `"/app"` | 容器内工作目录 |
-| `host_port` | int | `0` | 宿主机映射端口(Router 调用入口) |
-| `internal_port` | int | `0` | 容器/进程内服务端口 |
-| `endpoint_path` | str | `"/"` | HTTP API 路径 |
-| `http_method` | str | `"POST"` | HTTP 方法 |
-| `env` | dict | `{}` | 环境变量 |
-| `resource_limits` | ResourceLimits | `{}` | 资源限制 |
-
-### 1.4 ContainerInfo(容器信息)
+```json
+{
+  "type": "local",
+  "host_dir": "tools/local/image_compress_api",
+  "endpoint_path": "/",
+  "http_method": "POST",
+  "internal_port": 0
+}
+```
+
+Docker 类型:
+```json
+{
+  "type": "docker",
+  "container_id": "25e884ca6cec...",
+  "image": "ubuntu:22.04",
+  "internal_port": 8080,
+  "endpoint_path": "/api/compress",
+  "http_method": "POST"
+}
+```
+
+Hub 类型(外部 API):
+```json
+{
+  "type": "remote",
+  "remote_url": "https://api.example.com",
+  "remote_path": "/v1/compress",
+  "remote_api_key": "sk-***",
+  "endpoint_path": "/v1/compress",
+  "http_method": "POST"
+}
+```
+
+### 1.4 ToolRoute(运行状态)
+
+```json
+{
+  "tool_id": "image_compress_api",
+  "sources": [
+    {"type": "local", "host_dir": "tools/local/image_compress_api"}
+  ],
+  "active_source": 0,
+  "state": "running",
+  "pid": 12345,
+  "port": 52341,
+  "started_at": "2026-03-26T10:30:00Z",
+  "last_error": null
+}
+```
+
+### 1.5 ContainerInfo(容器信息)
 
 ```json
 {
@@ -126,60 +140,109 @@
 ```json
 {
   "tools": [
-    { /* ToolMeta 对象,见 1.2 */ }
+    {
+      "tool_id": "image_compress_api",
+      "name": "图片压缩 API",
+      "category": "cv",
+      "description": "基于 PIL 的图片压缩",
+      "input_schema": {},
+      "output_schema": {},
+      "stream_support": false,
+      "status": "active"
+    }
   ],
-  "version": "1.0"
+  "version": "2.0"
 }
 ```
 
 ### Registry 查询接口
 
-**get_endpoint(tool_id)** 返回格式:
+**ToolRegistry 类方法**
 
-Docker 类型:
-```json
-{
-  "type": "docker",
-  "url": "http://localhost:9001/api/compress",
-  "host_port": 9001,
-  "internal_port": 8080,
-  "container_id": "abc123...",
-  "host_dir": "C:/staging/project",
-  "http_method": "POST"
-}
-```
+| 方法 | 参数 | 返回 |
+|------|------|------|
+| `get(tool_id)` | tool_id | ToolMeta \| None |
+| `list_all()` | - | list[ToolMeta] |
+| `list_active()` | - | list[ToolMeta] |
+| `find_by_category(category)` | category | list[ToolMeta] |
+| `search(keyword)` | keyword | list[ToolMeta] |
+| `register(tool)` | ToolMeta | None |
+| `unregister(tool_id)` | tool_id | bool |
+| `destroy(tool_id)` | tool_id | dict(清理结果) |
+
+---
+
+## 3. 来源存储格式 (sources.json)
+
+路径:`data/sources.json`
 
-Local 类型:
 ```json
 {
-  "type": "local",
-  "host_dir": "C:/tools/local/my_tool",
-  "http_method": "POST",
-  "endpoint_path": "/"
+  "sources": {
+    "image_compress_api": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/image_compress_api",
+        "endpoint_path": "/",
+        "http_method": "POST",
+        "internal_port": 0
+      }
+    ],
+    "gpu_tool": [
+      {
+        "type": "docker",
+        "container_id": "abc123...",
+        "internal_port": 8080,
+        "endpoint_path": "/api/run",
+        "http_method": "POST"
+      }
+    ]
+  }
 }
 ```
 
+### SourceStore 接口
+
+| 方法 | 参数 | 返回 |
+|------|------|------|
+| `load()` | - | dict[str, list[dict]] |
+| `save(sources)` | sources | None |
+| `add_source(tool_id, source)` | tool_id, ToolSource | None |
+| `get_sources(tool_id)` | tool_id | list[ToolSource] |
+| `remove_tool(tool_id)` | tool_id | None |
+
 ---
 
-## 3. 容器状态表格式 (containers.json)
+## 4. 容器状态表格式 (containers.json)
 
 路径:`data/containers.json`
 
 ```json
 {
   "containers": [
-    { /* ContainerInfo 对象,见 1.4 */ }
+    {
+      "container_id": "25e884ca6cec...",
+      "tool_id": "gpu_tool",
+      "image": "ubuntu:22.04",
+      "port_mapping": {"8080": 9001},
+      "volumes": {"C:/staging/project": "/app"},
+      "mem_limit": "1g",
+      "nano_cpus": 1000000000,
+      "use_gpu": false,
+      "status": "running",
+      "created_at": "2026-03-20T07:31:50Z"
+    }
   ]
 }
 ```
 
 ---
 
-## 4. 任务书格式 (task_spec)
+## 5. 任务书格式 (task_spec)
 
-Router Agent 生成任务书,通过 MessageBus 发送给 Coding Agent。
+Router 生成任务书,通过 `asyncio.create_task` 提交给 CodingAgent。
 
-### 4.1 GitHub 项目接入任务
+### 5.1 GitHub 项目接入任务
 
 ```json
 {
@@ -187,93 +250,62 @@ Router Agent 生成任务书,通过 MessageBus 发送给 Coding Agent。
   "repo_url": "https://github.com/user/project",
   "tool_name": "project_api",
   "runtime": "docker",
-  "image": "python:3.12-slim",
-  "ports": [8080],
-  "description": "将该项目部署为 HTTP API 工具",
-  "requirements": {
-    "gpu": false,
-    "memory_mb": 512
-  },
-  "api_spec": {
-    "endpoint_path": "/api/run",
-    "http_method": "POST",
-    "input_schema": {
-      "type": "object",
-      "properties": {
-        "text": {"type": "string"}
-      }
-    }
-  }
+  "description": "将该项目部署为 HTTP API 工具"
 }
 ```
 
-### 4.2 自主编写工具任务
+### 5.2 自主编写工具任务
 
 ```json
 {
   "type": "build_tool",
   "tool_name": "text_summarizer_api",
   "runtime": "uv",
-  "description": "编写一个文本摘要工具,接受文本输入,返回摘要",
-  "requirements": {
-    "gpu": false,
-    "memory_mb": 256,
-    "dependencies": ["transformers", "torch"]
-  },
-  "api_spec": {
-    "endpoint_path": "/summarize",
-    "http_method": "POST",
-    "input_schema": {
-      "type": "object",
-      "properties": {
-        "text": {"type": "string", "description": "待摘要文本"},
-        "max_length": {"type": "integer", "description": "摘要最大长度"}
-      },
-      "required": ["text"]
-    }
-  }
+  "description": "编写一个文本摘要工具,接受文本输入,返回摘要"
 }
 ```
 
-### 4.3 工具修复任务
+### 5.3 工具修复任务
 
 ```json
 {
   "type": "repair_tool",
   "tool_id": "image_compress_api",
   "error": "HTTP 503: Service Unavailable",
-  "container_id": "abc123...",
   "description": "工具健康检查失败,需要诊断并修复"
 }
 ```
 
 ---
 
-## 5. 内部消息格式 (AgentMessage)
+## 6. 内部消息格式 (AgentMessage)
+
+Router 与 CodingAgent 通过 `MessageBus`(`asyncio.Queue`)通信。
 
-Router Agent 与 Coding Agent 通过 `asyncio.Queue` 通信。
+**当前状态**:MessageBus 已实现但未集成,Router 通过直接函数调用启动 CodingAgent
 
 ```json
 {
   "type": "tool_request | tool_ready | tool_error | health_alert",
-  "payload": { }
+  "payload": {}
 }
 ```
 
-### 5.1 tool_request(Router → Coding)
+### 6.1 tool_request(Router → Coding)
 
 ```json
 {
   "type": "tool_request",
   "payload": {
-    "task_spec": "{ /* 任务书 JSON,见第 4 节 */ }",
+    "task_spec": "{ /* 任务书 JSON */ }",
     "task_id": "550e8400-e29b-41d4-a716-446655440000",
-    "callback_url": "http://caller/callback"
+    "description": "需要一个图片压缩工具",
+    "reference_files": ["tools/local/example/main.py"]
   }
 }
 ```
 
-### 5.2 tool_ready(Coding → Router)
+### 6.2 tool_ready(Coding → Router)
 
 ```json
 {
@@ -281,13 +313,12 @@ Router Agent 与 Coding Agent 通过 `asyncio.Queue` 通信。
   "payload": {
     "tool_id": "image_compress_api",
     "result": "部署成功,工具已注册",
-    "url": "http://localhost:9001/api/compress",
     "task_id": "550e8400-..."
   }
 }
 ```
 
-### 5.3 tool_error(Coding → Router)
+### 6.3 tool_error(Coding → Router)
 
 ```json
 {
@@ -300,14 +331,13 @@ Router Agent 与 Coding Agent 通过 `asyncio.Queue` 通信。
 }
 ```
 
-### 5.4 health_alert(Router → Coding)
+### 6.4 health_alert(Router → Coding)
 
 ```json
 {
   "type": "health_alert",
   "payload": {
     "tool_id": "image_compress_api",
-    "container_id": "abc123...",
     "error": "HTTP 503",
     "last_healthy": "2026-03-20T10:00:00Z"
   }
@@ -316,7 +346,7 @@ Router Agent 与 Coding Agent 通过 `asyncio.Queue` 通信。
 
 ---
 
-## 6. 对外 HTTP 接口
+## 7. 对外 HTTP 接口
 
 基础地址:`http://localhost:8001`
 
@@ -327,11 +357,17 @@ Router Agent 与 Coding Agent 通过 `asyncio.Queue` 通信。
 {"status": "ok"}
 ```
 
-### GET /tools
+### POST /search_tools
 
-返回工具目录(按类别)
+搜索可用工具列表,返回工具信息及运行状态
 
 ```json
+// Request
+{
+  "keyword": "图片",
+  "category": "cv"
+}
+
 // Response
 {
   "tools": [
@@ -340,78 +376,254 @@ Router Agent 与 Coding Agent 通过 `asyncio.Queue` 通信。
       "name": "图片压缩 API",
       "category": "cv",
       "description": "基于 PIL 的图片压缩",
-      "status": "active"
+      "params": [
+        {
+          "name": "image_path",
+          "type": "string",
+          "description": "图片路径",
+          "required": true
+        }
+      ],
+      "required_params": ["image_path"],
+      "input_schema": {},
+      "output_schema": {},
+      "backend_runtime": "local",
+      "host_dir": "tools/local/image_compress_api",
+      "endpoint_path": "/",
+      "http_method": "POST",
+      "state": "running",
+      "port": 52341,
+      "pid": 12345
+    }
+  ],
+  "total": 1
+}
+```
+
+### POST /select_tool
+
+选择并调用工具(未启动则自动启动)。
+
+```json
+// Request
+{
+  "tool_id": "image_compress_api",
+  "params": {"image_path": "/path/to/img.jpg", "quality": 85},
+  "stream": false
+}
+
+// Response
+{
+  "status": "success",
+  "result": {"compressed_size": 256000, "compression_ratio": 0.25},
+  "error": null
+}
+```
+
+### POST /create_tool
+
+提交新工具创建需求(异步)。
+
+```json
+// Request
+{
+  "description": "需要一个图片压缩工具",
+  "task_spec": "详细任务描述..."
+}
+
+// Response
+{
+  "task_id": "create_a1b2c3d4",
+  "status": "pending",
+  "message": "Task submitted"
+}
+```
+
+### GET /tasks/{task_id}
+
+查询异步任务状态。
+
+```json
+// Response
+{
+  "task_id": "create_a1b2c3d4",
+  "status": "completed",
+  "result": "工具已成功注册",
+  "task_spec": "..."
+}
+```
+
+### GET /tools/status
+
+列出所有工具运行状态。
+
+```json
+// Response
+{
+  "tools": [
+    {
+      "tool_id": "image_compress_api",
+      "sources": [...],
+      "active_source": 0,
+      "state": "running",
+      "pid": 12345,
+      "port": 52341
     }
   ]
 }
 ```
 
-### GET /tools/{tool_id}/schema
+### POST /tools/{tool_id}/start
+
+手动启动工具。
 
 ```json
 // Response
 {
   "tool_id": "image_compress_api",
-  "input_schema": { /* JSON Schema */ },
-  "output_schema": { /* JSON Schema */ }
+  "state": "running",
+  "port": 52341
 }
 ```
 
-### POST /tools/{tool_id}/invoke
+### POST /tools/{tool_id}/stop
+
+手动停止工具。
 
 ```json
-// Request
+// Response
 {
-  "params": {"image_path": "/path/to/img.jpg", "quality": 85},
-  "stream": false
+  "tool_id": "image_compress_api",
+  "state": "stopped"
 }
+```
+
+### POST /agent/chat
+
+与 Router Agent 对话交互(自然语言)。
+
+**设计理念**:外部 Agent 通过自然语言与 Router 交互,Router 理解意图后返回结构化方案,更加人性化和灵活。
 
-// Response(非流式)
+**支持场景**:
+- 工具发现与推荐
+- 工具使用方案指导
+- 组合工具使用说明(如 ComfyUI)
+- 工具创建请求
+
+```json
+// Request
 {
-  "status": "success",
-  "result": {"compressed_size": 256000, "compression_ratio": 0.25},
-  "error": null
+  "message": "用户的自然语言请求",
+  "context": {}  // 可选的上下文信息
 }
 
-// Response(流式 SSE,Accept: text/event-stream)
-data: {"chunk": "处理中...", "done": false}
-data: {"chunk": "完成", "done": true}
+// Response
+{
+  "reply": "Router 的回复",
+  "tools": [],      // 相关工具列表(可选)
+  "solution": {},   // 解决方案(可选)
+  "task_id": "",    // 任务 ID(创建工具时)
+  "next_steps": []  // 后续操作建议
+}
 ```
 
-### POST /tools/request
+**示例 1:工具发现**
 
-提交新工具需求(异步)。
+```json
+// Request
+{
+  "message": "我需要处理图片的工具",
+  "context": {"task": "批量压缩图片"}
+}
+
+// Response
+{
+  "reply": "找到 2 个图片处理工具,推荐使用 image_compress_api",
+  "tools": [
+    {
+      "tool_id": "image_compress_api",
+      "name": "图片压缩 API",
+      "reason": "专门用于图片压缩,支持质量调节",
+      "usage_example": {
+        "params": {"image_path": "/path/to/image.jpg", "quality": 85}
+      }
+    }
+  ],
+  "next_steps": ["调用 /select_tool 使用该工具"]
+}
+```
+
+**示例 2:ComfyUI 工具使用指导**
 
 ```json
 // Request
 {
-  "description": "需要一个图片压缩工具",
-  "callback_url": "http://caller/callback"
+  "message": "如何使用 ComfyUI 的 LoadImage 和 SaveImage 节点?"
 }
 
 // Response
 {
-  "task_id": "550e8400-...",
-  "status": "pending"
+  "reply": "ComfyUI 节点已通过 runcomfy 工具接入,需要三步操作",
+  "solution": {
+    "type": "composite_workflow",
+    "base_tools": ["runcomfy_launch_env", "runcomfy_run_only", "runcomfy_stop_env"],
+    "steps": [
+      {
+        "step": 1,
+        "action": "启动环境",
+        "tool": "runcomfy_launch_env",
+        "params": {},
+        "returns": "container_id"
+      },
+      {
+        "step": 2,
+        "action": "执行 workflow",
+        "tool": "runcomfy_run_only",
+        "params": {
+          "container_id": "<from_step_1>",
+          "workflow": {
+            "nodes": [
+              {"id": 1, "type": "LoadImage", "inputs": {"image": "input.png"}},
+              {"id": 2, "type": "SaveImage", "inputs": {"images": ["1", 0]}}
+            ]
+          }
+        }
+      },
+      {
+        "step": 3,
+        "action": "清理环境",
+        "tool": "runcomfy_stop_env",
+        "params": {"container_id": "<from_step_1>"}
+      }
+    ]
+  }
 }
 ```
 
-### GET /tasks/{task_id}/status
+**示例 3:工具创建**
 
 ```json
+// Request
+{
+  "message": "帮我创建一个文本摘要工具",
+  "context": {"requirements": "支持中英文,最大长度 500 字"}
+}
+
 // Response
 {
-  "task_id": "550e8400-...",
-  "status": "completed",
-  "result": {"tool_id": "image_compress_api", "url": "http://localhost:9001/api/compress"}
+  "reply": "已提交工具创建任务,预计 2-3 分钟完成",
+  "task_id": "create_a1b2c3d4",
+  "status": "pending",
+  "tracking": "使用 GET /tasks/create_a1b2c3d4 查询进度",
+  "estimated_time": "2-3 分钟"
 }
 ```
 
 ---
 
-## 7. Coding Agent 工具接口
+## 8. Coding Agent 工具接口
 
-Coding Agent 通过 claude_agent_sdk 暴露以下 10 个工具:
+CodingAgent 通过 `claude_agent_sdk` 暴露以下 10 个工具:
 
 ### Docker 环境
 
@@ -427,7 +639,7 @@ Coding Agent 通过 claude_agent_sdk 暴露以下 10 个工具:
 | 工具 | 必填参数 | 可选参数 | 返回 |
 |------|----------|----------|------|
 | `create_uv_project` | `name` | `python_version` | `project_dir` |
-| `run_in_uv` | `project_dir`, `command` | `timeout` | `exit_code`, `stdout`, `stderr` |
+| `run_in_uv` | `project_dir`, `command` | `is_background`, `timeout` | `exit_code`, `stdout`, `stderr` |
 | `uv_add_dependency` | `project_dir`, `package` | `dev` | `status`, `message` |
 
 ### 文件操作
@@ -441,40 +653,7 @@ Coding Agent 通过 claude_agent_sdk 暴露以下 10 个工具:
 
 | 工具 | 必填参数 | 可选参数 | 返回 |
 |------|----------|----------|------|
-| `register_tool` | `tool_id`, `name`, `description`, `runtime_type`, `host_port`, `internal_port` | `category`, `input_schema`, `output_schema`, `container_id`, `host_dir`, `endpoint_path`, `http_method` | `status`, `tool_id`, `url` |
-
----
-
-## 8. 资源监控格式
-
-### SystemInfo(系统概览)
-
-```json
-{
-  "cpu_count": 16,
-  "cpu_percent": 11.1,
-  "memory_total_mb": 15653,
-  "memory_available_mb": 2606,
-  "memory_percent": 83.4,
-  "disk_total_gb": 924.3,
-  "disk_free_gb": 327.3
-}
-```
-
-### ResourceUsage(单工具资源占用)
-
-```json
-{
-  "cpu_cores": 1.0,
-  "memory_mb": 512.0,
-  "gpu_memory_mb": 0.0
-}
-```
-
-### 资源分配检查规则
-
-- CPU:已分配 + 新请求 ≤ 系统总核数
-- 内存:已分配 + 新请求 ≤ 系统总内存 × 80%(保留 20% 安全余量)
+| `register_tool` | `tool_id`, `name`, `description`, `runtime_type`, `internal_port` | `category`, `input_schema`, `output_schema`, `container_id`, `host_dir`, `endpoint_path`, `http_method`, `group_ids` | `status`, `tool_id`, `backend_runtime`, `message` |
 
 ---
 
@@ -499,25 +678,129 @@ Coding Agent 通过 claude_agent_sdk 暴露以下 10 个工具:
 | `require_approval_above_usd` | float | `10.0` | 需审批阈值 |
 | `health_check_interval_s` | int | `60` | 健康检查间隔 |
 
-### 运行时配置覆盖 (data/config.json)
+---
+
+## 10. Router 工具匹配与对接
+
+### 10.1 职责
+
+Router 负责与外部 Agent 对话,将自身工具库与外部工具表进行匹配和对接。
+
+### 10.2 工具匹配策略
+
+当外部 Agent 请求工具时,Router 执行以下匹配逻辑:
+
+```
+外部 Agent 请求工具 X
+       │
+       ▼
+Router 查询 registry.json
+       │
+       ├─ 直接匹配:tool_id 或 name 完全匹配
+       │   → 返回工具信息,调用 /select_tool
+       │
+       ├─ 模糊匹配:keyword 匹配 description
+       │   → 返回候选列表,由外部 Agent 选择
+       │
+       ├─ 组合工具匹配:检测是否为复合工具需求
+       │   → 例如:ComfyUI 节点工具
+       │   → 检查是否有基础工具(如 runcomfy)
+       │   → 返回基础工具 + 使用说明
+       │
+       └─ 无匹配:返回 404
+           → 外部 Agent 可选择提交 /create_tool 请求
+```
+
+### 10.3 ComfyUI 工具对接示例
+
+**场景**:外部 Agent 请求使用 ComfyUI 内部节点(如 `LoadImage`, `SaveImage`)
+
+**Router 响应策略**:
+
+1. **检测 ComfyUI 相关关键词**:
+   - 请求中包含 `comfyui`, `workflow`, `node` 等关键词
+   - 或请求的 tool_id 包含 comfyui 相关命名
+
+2. **查询基础工具**:
+   - 检查 registry 中是否有 `runcomfy` 相关工具
+   - 例如:`runcomfy_launch_env`, `runcomfy_run_only`, `runcomfy_stop_env`
+
+3. **返回组合工具信息**:
 
 ```json
 {
-  "cold_tool_idle_timeout_s": 300,
-  "hot_tool_max_containers": 5,
-  "eviction_policy": "lru",
-  "budget": {
-    "monthly_limit_usd": 100,
-    "single_tx_limit_usd": 20,
-    "require_approval_above_usd": 10,
-    "spent_this_month_usd": 0
+  "status": "composite_tool",
+  "message": "ComfyUI 节点工具已通过 runcomfy 基础工具接入",
+  "base_tools": [
+    {
+      "tool_id": "runcomfy_launch_env",
+      "name": "启动 ComfyUI 环境",
+      "description": "启动 ComfyUI Docker 环境,返回容器 ID 和端口",
+      "usage": "先调用此工具启动环境,获取 container_id"
+    },
+    {
+      "tool_id": "runcomfy_run_only",
+      "name": "执行 ComfyUI Workflow",
+      "description": "在已启动的环境中执行 workflow JSON",
+      "usage": "传入 workflow JSON 和 container_id,执行节点流程"
+    },
+    {
+      "tool_id": "runcomfy_stop_env",
+      "name": "停止 ComfyUI 环境",
+      "description": "停止并清理 ComfyUI 容器",
+      "usage": "任务完成后调用,释放资源"
+    }
+  ],
+  "workflow_example": {
+    "description": "使用 LoadImage 和 SaveImage 节点的示例",
+    "steps": [
+      "1. 调用 runcomfy_launch_env 启动环境",
+      "2. 构造 workflow JSON(包含 LoadImage, SaveImage 节点)",
+      "3. 调用 runcomfy_run_only 执行 workflow",
+      "4. 获取输出结果",
+      "5. 调用 runcomfy_stop_env 清理环境"
+    ],
+    "workflow_json": {
+      "nodes": [
+        {"id": 1, "type": "LoadImage", "inputs": {"image": "input.png"}},
+        {"id": 2, "type": "SaveImage", "inputs": {"images": ["1", 0]}}
+      ]
+    }
   }
 }
 ```
 
+### 10.4 工具对接接口
+
+Router 提供以下内部方法支持工具匹配:
+
+| 方法 | 参数 | 返回 | 说明 |
+|------|------|------|------|
+| `match_tool(query)` | query: str | ToolMeta \| None | 精确匹配 tool_id 或 name |
+| `search_tools(keyword, category)` | keyword, category | list[ToolMeta] | 模糊搜索 |
+| `detect_composite_tool(query)` | query: str | dict \| None | 检测是否为组合工具需求 |
+| `get_base_tools(composite_type)` | composite_type: str | list[ToolMeta] | 获取基础工具列表 |
+
+### 10.5 扩展:外部工具表对接
+
+**未来规划**:Router 可对接外部工具表(如其他 Agent 系统的工具库)
+
+```json
+// 外部工具表注册
+POST /external_tools/register
+{
+  "source": "agent_system_x",
+  "tools_url": "https://agent-x.com/api/tools",
+  "api_key": "sk-***"
+}
+
+// Router 查询时同时搜索本地 + 外部工具表
+// 返回统一格式的工具列表
+```
+
 ---
 
-## 10. 工作日志格式
+## 附录:工作日志格式
 
 ### LocalRunner 命令日志 (last_run.log)
 
@@ -532,39 +815,13 @@ Server started on port 8080
 WARNING: Using development server
 ```
 
-### Coding Agent 执行日志(stdout)
+### CodingAgent 执行日志(stdout)
 
 ```
-2026-03-20 15:09:32 [tool_agent.tool.agent] INFO: [CodingAgent] Starting task: 部署 flask 项目...
-2026-03-20 15:09:35 [tool_agent.tool.agent] INFO: [TOOL_USE] create_docker_env | {"image":"python:3.12-slim","ports":[8080]}
-2026-03-20 15:09:40 [tool_agent.tool.agent] INFO: [TEXT] 正在创建 Docker 环境...
-2026-03-20 15:10:15 [tool_agent.tool.agent] INFO: [TOOL_USE] register_tool | {"tool_id":"flask_api",...}
-2026-03-20 15:10:16 [tool_agent.tool.agent] INFO: [DONE] duration=44000ms
-2026-03-20 15:10:16 [tool_agent.tool.agent] INFO: [COST] $0.12
+2026-03-26 15:09:32 [tool_agent.tool.agent] INFO: [CodingAgent] Starting task: 部署 flask 项目...
+2026-03-26 15:09:35 [tool_agent.tool.agent] INFO: [TOOL_USE] create_docker_env | {"image":"python:3.12-slim","ports":[8080]}
+2026-03-26 15:09:40 [tool_agent.tool.agent] INFO: [TEXT] 正在创建 Docker 环境...
+2026-03-26 15:10:15 [tool_agent.tool.agent] INFO: [TOOL_USE] register_tool | {"tool_id":"flask_api",...}
+2026-03-26 15:10:16 [tool_agent.tool.agent] INFO: [DONE] duration=44000ms
+2026-03-26 15:10:16 [tool_agent.tool.agent] INFO: [COST] $0.12
 ```
-
----
-
-## 11. 财务记录格式
-
-路径:`data/billing_log.json`
-
-```json
-{
-  "transactions": [
-    {
-      "provider": "openai",
-      "amount_usd": 5.0,
-      "description": "API Key 充值"
-    }
-  ]
-}
-```
-
-### 预算检查规则
-
-| 条件 | 行为 |
-|------|------|
-| `amount ≤ single_tx_limit_usd` | Agent 自主完成 |
-| `amount > require_approval_above_usd` | 暂停,通知用户审批 |
-| `spent_this_month + amount > monthly_limit_usd` | 拒绝,通知预算超限 |

+ 137 - 0
docs/tool_table/resource-storage-examples.md

@@ -0,0 +1,137 @@
+# Resource存储系统使用示例
+
+## 环境配置
+
+在`.env`文件中配置组织密钥:
+
+```bash
+# 生成密钥(Python)
+python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
+
+# 配置到.env
+ORG_KEYS=test:生成的密钥base64,prod:另一个密钥base64
+```
+
+## 使用示例
+
+### 1. 存储代码片段
+
+```bash
+curl -X POST http://localhost:8000/api/resource \
+  -H "Content-Type: application/json" \
+  -d '{
+    "id": "test/code/selenium",
+    "title": "Selenium绕过检测",
+    "body": "import undetected_chromedriver as uc\ndriver = uc.Chrome()",
+    "content_type": "code",
+    "metadata": {"language": "python"},
+    "submitted_by": "user@example.com"
+  }'
+```
+
+### 2. 存储账号密码(加密)
+
+```bash
+curl -X POST http://localhost:8000/api/resource \
+  -H "Content-Type: application/json" \
+  -d '{
+    "id": "test/credentials/website",
+    "title": "某网站登录凭证",
+    "body": "使用方法:直接登录即可",
+    "secure_body": "账号:user@example.com\n密码:SecurePass123",
+    "content_type": "credential",
+    "metadata": {"acquired_at": "2026-03-06T10:00:00Z"},
+    "submitted_by": "user@example.com"
+  }'
+```
+
+### 3. 存储Cookie(加密)
+
+```bash
+curl -X POST http://localhost:8000/api/resource \
+  -H "Content-Type: application/json" \
+  -d '{
+    "id": "test/cookies/website",
+    "title": "某网站Cookie",
+    "body": "适用于:已登录状态的API调用",
+    "secure_body": "session_id=abc123; auth_token=xyz789",
+    "content_type": "cookie",
+    "metadata": {
+      "acquired_at": "2026-03-06T10:00:00Z",
+      "expires_at": "2026-03-07T10:00:00Z"
+    },
+    "submitted_by": "user@example.com"
+  }'
+```
+
+### 4. 获取资源(无密钥)
+
+```bash
+# 公开内容正常返回,敏感内容显示[ENCRYPTED]
+curl http://localhost:8000/api/resource/test/credentials/website
+```
+
+### 5. 获取资源(有密钥)
+
+```bash
+# 敏感内容解密返回
+curl http://localhost:8000/api/resource/test/credentials/website \
+  -H "X-Org-Key: 你的密钥base64"
+```
+
+### 6. 更新资源
+
+```bash
+curl -X PATCH http://localhost:8000/api/resource/test/credentials/website \
+  -H "Content-Type: application/json" \
+  -d '{
+    "title": "更新后的标题",
+    "metadata": {"acquired_at": "2026-03-06T11:00:00Z"}
+  }'
+```
+
+### 7. 列出所有资源
+
+```bash
+curl http://localhost:8000/api/resource
+```
+
+### 8. 按类型过滤
+
+```bash
+curl "http://localhost:8000/api/resource?content_type=credential"
+```
+
+## Knowledge引用Content
+
+在Knowledge中引用Content资源:
+
+```bash
+curl -X POST http://localhost:8000/api/knowledge \
+  -H "Content-Type: application/json" \
+  -d '{
+    "task": "登录某网站",
+    "content": "使用Selenium + undetected_chromedriver绕过检测,详见content资源",
+    "types": ["tool"],
+    "tags": {
+      "content_ref": "test/code/selenium",
+      "credential_ref": "test/credentials/website"
+    },
+    "owner": "agent:test",
+    "source": {
+      "submitted_by": "user@example.com"
+    }
+  }'
+```
+
+## 测试脚本
+
+运行测试脚本验证功能:
+
+```bash
+# 启动服务器
+uvicorn knowhub.server:app --reload
+
+# 运行测试(另一个终端)
+python test_content_storage.py
+```

+ 289 - 0
docs/tool_table/resource-storage.md

@@ -0,0 +1,289 @@
+# Resource 存储系统
+
+## 概述
+
+Resource 存储系统用于管理原始资源(代码片段、凭证、Cookie等),与 Knowledge 系统互补:
+- **Knowledge**:结构化知识条目(任务场景 + 经验内容)
+- **Content**:原始资源存储(代码、凭证、配置等)
+
+Knowledge 可以通过 `resource_id` 引用 Content 资源。
+
+---
+
+## 数据结构
+
+### 数据库表
+
+实现位置:`knowhub/server.py:init_db`
+
+```sql
+CREATE TABLE resources (
+    id            TEXT PRIMARY KEY,        -- 层级ID,如 "tools/selenium/login"
+    title         TEXT DEFAULT '',
+    body          TEXT NOT NULL,           -- 公开内容
+    secure_body   TEXT DEFAULT '',         -- 敏感内容(加密存储)
+    content_type  TEXT DEFAULT 'text',     -- text|code|credential|cookie
+    metadata      TEXT DEFAULT '{}',       -- JSON: {language, acquired_at, expires_at}
+    sort_order    INTEGER DEFAULT 0,
+    submitted_by  TEXT DEFAULT '',
+    created_at    TEXT NOT NULL,
+    updated_at    TEXT DEFAULT ''
+)
+```
+
+### 字段说明
+
+- **id**: 层级标识符,支持路径格式(如 `tools/selenium/login`)
+- **title**: 资源标题
+- **body**: 公开内容(明文存储,可搜索)
+- **secure_body**: 敏感内容(加密存储,需要组织密钥访问)
+- **content_type**: 内容类型
+  - `text`: 普通文本
+  - `code`: 代码片段
+  - `credential`: 账号密码
+  - `cookie`: Cookie数据
+- **metadata**: 元数据(JSON对象)
+  - `language`: 代码语言(当 content_type=code)
+  - `acquired_at`: 获取时间(ISO 8601格式)
+  - `expires_at`: 过期时间(用于cookie)
+- **sort_order**: 排序顺序(同级内容排序)
+- **submitted_by**: 提交者标识
+- **created_at**: 创建时间
+- **updated_at**: 更新时间
+
+---
+
+## 加密机制
+
+### 组织密钥
+
+敏感内容使用 AES-256-GCM 加密,密钥从环境变量读取:
+
+```bash
+# .env
+ORG_KEYS=org1:key1_base64,org2:key2_base64
+```
+
+每个 content 的 `id` 前缀决定使用哪个组织密钥(如 `org1/tools/...` 使用 `org1` 的密钥)。
+
+### 加密存储格式
+
+```
+encrypted:AES256-GCM:{base64_encoded_data}
+```
+
+实现位置:`knowhub/server.py:encrypt_resource`, `decrypt_resource`
+
+### 访问控制
+
+读取包含 `secure_body` 的 content 时:
+- 需要提供 `X-Org-Key` HTTP 头
+- 验证通过后解密返回
+- 验证失败返回 `[ENCRYPTED]` 占位符
+
+---
+
+## API 端点
+
+### `POST /api/content`
+
+提交新资源。
+
+**请求体**:
+
+```json
+{
+  "id": "tools/selenium/login",
+  "title": "Selenium登录代码",
+  "body": "使用undetected_chromedriver绕过检测",
+  "secure_body": "账号:xxx 密码:xxx",
+  "content_type": "credential",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z"
+  },
+  "submitted_by": "user@example.com"
+}
+```
+
+实现位置:`knowhub/server.py:submit_resource`
+
+### `GET /api/content/{resource_id}`
+
+获取资源(支持层级路径)。
+
+**请求头**(可选):
+```
+X-Org-Key: your_org_key_here
+```
+
+**响应**:
+
+```json
+{
+  "id": "tools/selenium/login",
+  "title": "Selenium登录代码",
+  "body": "使用undetected_chromedriver绕过检测",
+  "secure_body": "账号:xxx 密码:xxx",  // 有密钥时解密
+  "content_type": "credential",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z"
+  },
+  "toc": {"id": "tools", "title": "工具集"},
+  "children": [
+    {"id": "tools/selenium/login", "title": "登录代码"}
+  ]
+}
+```
+
+实现位置:`knowhub/server.py:get_resource`
+
+### `PATCH /api/content/{resource_id}`
+
+更新资源字段。
+
+**请求体**(所有字段可选):
+
+```json
+{
+  "title": "新标题",
+  "body": "新的公开内容",
+  "secure_body": "新的敏感内容",
+  "metadata": {
+    "expires_at": "2026-03-07T10:00:00Z"
+  }
+}
+```
+
+实现位置:`knowhub/server.py:patch_resource`
+
+### `GET /api/content`
+
+列出所有资源(支持过滤)。
+
+**参数**:
+- `content_type`: 按类型过滤(可选)
+- `limit`: 返回数量(默认100)
+
+实现位置:`knowhub/server.py:list_resources`
+
+---
+
+## 使用场景
+
+### 场景1:存储复杂代码
+
+```json
+{
+  "id": "code/selenium/undetected",
+  "title": "Selenium绕过检测",
+  "body": "import undetected_chromedriver as uc\n\ndriver = uc.Chrome()\n...",
+  "content_type": "code",
+  "metadata": {
+    "language": "python"
+  }
+}
+```
+
+### 场景2:存储账号密码
+
+```json
+{
+  "id": "credentials/某网站",
+  "title": "某网站登录凭证",
+  "body": "使用方法:直接登录即可",
+  "secure_body": "账号:user@example.com\n密码:SecurePass123",
+  "content_type": "credential",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z"
+  }
+}
+```
+
+### 场景3:存储Cookie
+
+```json
+{
+  "id": "cookies/某网站",
+  "title": "某网站Cookie",
+  "body": "适用于:已登录状态的API调用",
+  "secure_body": "session_id=abc123; auth_token=xyz789",
+  "content_type": "cookie",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z",
+    "expires_at": "2026-03-07T10:00:00Z"
+  }
+}
+```
+
+### 场景4:Knowledge引用Content
+
+```json
+{
+  "task": "登录某网站",
+  "content": "使用Selenium + undetected_chromedriver绕过检测",
+  "resource_id": "code/selenium/undetected",
+  "secure_resource_id": "credentials/某网站"
+}
+```
+
+---
+
+## 层级结构
+
+Content 通过 ID 路径实现树形组织:
+
+```
+tools/
+├── selenium/
+│   ├── login
+│   └── scraping
+└── api/
+    └── requests
+
+credentials/
+├── 网站A
+└── 网站B
+
+cookies/
+└── 网站A
+```
+
+获取 `tools/selenium/login` 时自动计算:
+- **TOC**: 根节点 `tools`
+- **Children**: 子节点列表
+- **Prev/Next**: 同级节点导航
+
+---
+
+## 安全考虑
+
+1. **加密存储**:敏感内容使用 AES-256-GCM 加密
+2. **访问控制**:需要组织密钥才能解密
+3. **密钥管理**:密钥存储在环境变量,不入库
+4. **审计日志**:记录 `submitted_by` 和时间戳
+5. **过期提醒**:Cookie 可设置 `expires_at`
+
+---
+
+## 与 Knowledge 的关系
+
+| 维度 | Knowledge | Content |
+|------|-----------|---------|
+| 用途 | 结构化知识(经验、策略) | 原始资源(代码、凭证) |
+| 存储 | task + content 字段 | body + secure_body 字段 |
+| 搜索 | 语义搜索 + 质量排序 | 层级浏览 |
+| 引用 | 可引用 resource_id | 被 knowledge 引用 |
+| 加密 | 不加密(或整体加密) | 分离公开/敏感内容 |
+
+---
+
+## 实现位置
+
+| 组件 | 文件路径 |
+|------|---------|
+| 数据库表 | `knowhub/server.py:init_db` |
+| 加密/解密 | `knowhub/server.py:encrypt_resource`, `decrypt_resource` |
+| POST /api/content | `knowhub/server.py:submit_resource` |
+| GET /api/content/{id} | `knowhub/server.py:get_resource` |
+| PATCH /api/content/{id} | `knowhub/server.py:patch_resource` |
+| GET /api/content | `knowhub/server.py:list_resources` |

+ 290 - 0
docs/tool_table/tool-table-integration.md

@@ -0,0 +1,290 @@
+# 工具表(Resource)与知识库对接文档
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `knowhub/docs/decisions.md` 另行记录
+
+---
+
+## 一、核心概念
+
+知识库(KnowHub)中的工具表存储在 **Resource** 模块里,与 **Knowledge**(知识条目)通过双向索引关联。
+
+| 模块 | 存储后端 | 主键格式 |
+|------|----------|----------|
+| Resource(工具表) | SQLite | `tools/{category}/{slug}` |
+| Knowledge(知识) | Milvus(向量库) | `knowledge-{日期}-{随机}` |
+
+---
+
+## 二、Resource 数据结构
+
+### 工具表字段
+
+```json
+{
+  "id": "tools/plugin/ip_adapter",
+  "title": "IP-Adapter",
+  "body": "",
+  "content_type": "text",
+  "metadata": {
+    "category": "plugin",
+    "tool_slug": "ip_adapter",
+    "status": "未接入",
+    "version": "v1.0",
+    "description": "22M参数的轻量适配器,通过CLIP ViT-H图像编码器实现图像提示",
+    "usage": "scale=1.0用于纯图像提示,scale=0.5用于多模态(图像+文本)",
+    "scenarios": ["人物一致性控制", "风格迁移", "图像引导生成"],
+    "input": "参考图像 + 文本提示(可选)",
+    "output": "生成图像",
+    "source": "https://github.com/tencent-ailab/IP-Adapter",
+    "knowledge_ids": ["knowledge-20260309-215835-a699"],
+    "toolhub_items": [
+      {"launch_comfy_env": "启动云端 ComfyUI Docker 环境,返回 server_id 和 comfy_url"},
+      {"runcomfy_workflow_executor": "在已就绪的 RunComfy 机器上提交 ComfyUI 工作流,上传输入文件,监听执行状态,下载结果图片"},
+      {"runcomfy_stop_env": "停止并删除 RunComfy 机器实例,释放资源"}
+    ],
+    "migrated_from": "knowhub_old"
+  },
+  "submitted_by": "migrate_script"
+}
+```
+
+### metadata 字段说明
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `category` | string | ✅ | 分类:`image_gen` / `image_process` / `model` / `plugin` / `workflow` / `other` |
+| `tool_slug` | string | ✅ | 小写英文短名,空格换下划线,如 `controlnet`、`ip_adapter` |
+| `status` | string | ✅ | 接入状态:`未接入` / `已接入` / `测试中` |
+| `version` | string\|null | — | 版本号 |
+| `description` | string\|null | — | 一句话功能介绍 |
+| `usage` | string\|null | — | 核心用法说明 |
+| `scenarios` | array | — | 应用场景列表 |
+| `input` | string\|null | — | 输入类型描述 |
+| `output` | string\|null | — | 输出类型描述 |
+| `source` | string\|null | — | 来源/文档链接 |
+| `knowledge_ids` | array | — | 关联的知识 ID 列表(双向索引) |
+| `toolhub_items` | array | — | 关联的 Tool Agent 工具列表,每项为 `{tool_id: description}`,表示该工具在 Tool Agent 中对应的已接入工具 |
+
+### ID 命名规则
+
+```
+tools/{category}/{slug}
+
+示例:
+tools/plugin/ip_adapter
+tools/plugin/controlnet
+tools/image_gen/midjourney
+tools/model/sdxl
+```
+
+---
+
+## 三、双向索引机制
+
+```
+Resource(工具)                     Knowledge(知识)
+┌─────────────────────────┐         ┌──────────────────────────────┐
+│ id: tools/plugin/ip_adapter │◄────►│ id: knowledge-20260309-xxx   │
+│ metadata.knowledge_ids:  │         │ resource_ids:                │
+│   ["knowledge-xxx"]      │         │   ["tools/plugin/ip_adapter"] │
+└─────────────────────────┘         │ tags: {"tool": true}         │
+                                     └──────────────────────────────┘
+```
+
+- **Resource → Knowledge**:`metadata.knowledge_ids` 存储关联知识的 ID 列表
+- **Knowledge → Resource**:`resource_ids` 存储关联工具的 ID 列表,同时打 `tags.tool = true` 标记
+
+### 重要:双向索引是手动维护的
+
+**目前没有任何自动同步机制**,两侧的索引相互独立存储,需要调用方自己负责保持一致。
+
+两侧的写入时机:
+
+| 方向 | 存在哪 | 写入时机 |
+|------|--------|----------|
+| Resource → Knowledge | SQLite `resources.metadata.knowledge_ids` | 创建/更新 Resource 时手动 PATCH |
+| Knowledge → Resource | Milvus `resource_ids` 字段 | Knowledge 创建时传入,**创建后无法通过 API 修改** |
+
+> Knowledge 的 `resource_ids` 字段目前**不在 PATCH 接口的支持范围内**,一旦 Knowledge 创建,只能通过删除重建来更改其关联的工具。
+
+---
+
+## 四、API 接口
+
+服务地址由环境变量 `KNOWHUB_API` 指定(如 `http://localhost:9999`)。
+
+### 4.1 工具表(Resource)
+
+#### 创建工具
+```http
+POST /api/resource
+Content-Type: application/json
+
+{
+  "id": "tools/plugin/controlnet",
+  "title": "ControlNet",
+  "body": "",
+  "content_type": "text",
+  "metadata": {
+    "category": "plugin",
+    "tool_slug": "controlnet",
+    "status": "已接入",
+    "description": "...",
+    "usage": "...",
+    "scenarios": [...],
+    "knowledge_ids": []
+  },
+  "submitted_by": "your_name"
+}
+```
+
+#### 更新工具(局部更新,推荐)
+```http
+PATCH /api/resource/tools/plugin/controlnet
+Content-Type: application/json
+
+{
+  "metadata": {
+    "status": "已接入",
+    "knowledge_ids": ["knowledge-xxx", "knowledge-yyy"]
+  }
+}
+```
+> ⚠️ PATCH metadata 会**整体替换** metadata 字段,需带上所有已有字段。
+
+#### 获取工具详情
+```http
+GET /api/resource/tools/plugin/controlnet
+```
+
+返回额外的导航字段:`toc`(根节点)、`children`(子节点)、`prev`/`next`(同级导航)。
+
+#### 列出所有工具
+```http
+GET /api/resource?limit=1000
+```
+
+过滤出工具(客户端过滤):
+```javascript
+const tools = results.filter(r => r.id.startsWith("tools/"));
+```
+
+#### 删除工具
+```http
+DELETE /api/resource/tools/plugin/controlnet
+```
+
+---
+
+### 4.2 知识(Knowledge)
+
+#### 搜索工具相关知识(语义搜索)
+```http
+GET /api/knowledge/search?q=IP-Adapter人物一致性&top_k=5&min_score=3
+```
+
+#### 列出所有工具相关知识
+```http
+GET /api/knowledge?tags=tool&status=approved,checked&page_size=200
+```
+
+#### 获取单条知识
+```http
+GET /api/knowledge/knowledge-20260309-215835-a699
+```
+
+#### 批量删除知识
+```http
+POST /api/knowledge/batch_delete
+Content-Type: application/json
+
+["knowledge-xxx", "knowledge-yyy"]
+```
+
+---
+
+## 五、典型操作示例(Python)
+
+### 新增一个工具并关联知识
+
+```python
+import httpx
+
+KNOWHUB_API = "http://your-server:9999"
+
+# 1. 创建工具
+tool = {
+    "id": "tools/plugin/controlnet",
+    "title": "ControlNet",
+    "body": "",
+    "content_type": "text",
+    "metadata": {
+        "category": "plugin",
+        "tool_slug": "controlnet",
+        "status": "已接入",
+        "version": "v1.1",
+        "description": "基于条件图像控制SD生成的插件",
+        "usage": "提供边缘图/骨骼图/深度图等条件,精确控制生成结果",
+        "scenarios": ["姿势控制", "边缘控制", "深度控制"],
+        "input": "条件图像(canny/pose/depth等)+ 文本提示",
+        "output": "生成图像",
+        "source": "https://github.com/lllyasviel/ControlNet",
+        "knowledge_ids": []
+    },
+    "submitted_by": "your_name"
+}
+
+resp = httpx.post(f"{KNOWHUB_API}/api/resource", json=tool)
+print(resp.json())  # {"status": "ok", "id": "tools/plugin/controlnet"}
+
+# 2. 回填知识关联(PATCH 更新 knowledge_ids)
+patch = {
+    "metadata": {
+        **tool["metadata"],  # 保留所有已有字段
+        "knowledge_ids": ["knowledge-20260309-215835-a699"]
+    }
+}
+
+resp = httpx.patch(f"{KNOWHUB_API}/api/resource/tools/plugin/controlnet", json=patch)
+print(resp.json())  # {"status": "ok"}
+```
+
+### 更新工具接入状态
+
+```python
+# 先获取当前 metadata,再更新
+resp = httpx.get(f"{KNOWHUB_API}/api/resource/tools/plugin/controlnet")
+tool = resp.json()
+meta = tool["metadata"]
+
+meta["status"] = "已接入"
+
+httpx.patch(
+    f"{KNOWHUB_API}/api/resource/tools/plugin/controlnet",
+    json={"metadata": meta}
+)
+```
+
+---
+
+## 六、前端查看
+
+访问 KnowHub 管理界面,点击右上角 **🔧 工具表** 按钮:
+
+- 按 `category` Tab 切换分类
+- 左侧列表点击工具查看详情
+- 详情页显示:基础概览 / 使用指南 / 技术规格 / 消息信源 / 关联知识(可跳转)
+- 知识卡片底部有工具 Tag,点击可跳回工具表对应条目
+
+---
+
+## 七、注意事项
+
+1. **PATCH metadata 是整体替换**:更新前先 GET 拿到当前值,合并后再 PATCH,避免覆盖已有字段(尤其是 `knowledge_ids`)。
+2. **Resource ID 含斜杠**:URL 调用时需注意路径编码(框架已支持 `{resource_id:path}`,直接拼接即可,无需手动 encode)。
+3. **双向索引需手动维护**:创建/删除工具时,需同步更新关联 Knowledge 的 `resource_ids` 字段(PATCH `/api/knowledge/{id}`)。
+4. **工具筛选靠客户端**:`GET /api/resource` 返回所有 resource,需在客户端过滤 `id.startsWith("tools/")` 得到工具列表。

+ 3 - 0
pyproject.toml

@@ -37,4 +37,7 @@ members = [
     "tools/local/image_stitcher",
     "tools/local/liblibai_controlnet",
     "tools/local/launch_comfy_env",
+    "tools/local/run_comfy_workflow",
+    "tools/local/task_0cd69d84",
+    "tools/local/runcomfy_stop_env",
 ]

+ 263 - 0
src/im-client/AGENT_GUIDE.md

@@ -0,0 +1,263 @@
+# Agent 接入 IM 系统指南
+
+本文档面向 Agent 开发者(包括 Claude Code),说明如何为 Agent 接入 IM 通信能力。
+
+---
+
+## 前置条件
+
+1. IM Server 已启动: `cd im-server && uvicorn main:app --port 8000`
+2. 安装依赖: `pip install websockets pydantic filelock`
+
+---
+
+## 核心概念
+
+| 概念 | 说明 |
+|---|---|
+| `contact_id` | Agent 在 IM 系统中的唯一身份标识,如 `"agent_alice"` |
+| `IMClient` | 长驻 asyncio 服务,负责 WebSocket 连接和文件读写 |
+| `chat_id` | 窗口模式下的会话隔离 ID,每次 Agent 运行可生成新的 |
+| Server | 纯转发,只管路由,不存消息 |
+
+---
+
+## 快速接入(3 步)
+
+### 第 1 步:启动 Client
+
+```python
+import asyncio
+import sys
+sys.path.insert(0, "/path/to/IM-Server/im-client")
+
+from client import IMClient
+
+client = IMClient(
+    contact_id="my_agent",          # 你的 Agent ID
+    server_url="ws://localhost:8000" # Server 地址
+)
+
+# 后台启动(在 Agent 的 asyncio 事件循环中)
+asyncio.create_task(client.run())
+```
+
+### 第 2 步:发消息
+
+```python
+# 给 contact_id 为 "bob" 的联系人发消息
+client.send_message(receiver="bob", content="你好,我是 my_agent")
+
+# 发送图片(用 URL)
+client.send_message(receiver="bob", content="https://example.com/img.png", msg_type="image")
+```
+
+消息会立即进入发送队列,由 Client 通过 WebSocket 发出��
+
+### 第 3 步:收消息
+
+```python
+# 读取并清空待处理消息
+messages = client.read_pending()
+for msg in messages:
+    sender = msg["sender"]       # 谁发的
+    content = msg["content"]     # 消息内容
+    msg_type = msg["msg_type"]   # chat | image | video
+    print(f"{sender}: {content}")
+```
+
+调用 `read_pending()` 后,`in_pending.json` 会被清空。消息已永久记录在 `chatbox.jsonl` 中。
+
+---
+
+## 查看联系人
+
+Server 维护联系人表,通过 HTTP API 查询:
+
+```bash
+# 查看某用户的联系人
+curl http://localhost:8000/contacts/my_agent
+
+# 添加联系人
+curl -X POST "http://localhost:8000/contacts/my_agent/add?contact_id=bob"
+
+# 查看谁在线
+curl http://localhost:8000/health
+```
+
+Agent 可以通过 HTTP 请求查询联系人列表,决定给谁发消息。所有在 Server 联系人表中的用户,只要在线就可以互相通信。
+
+---
+
+## 窗口模式(推荐 Agent 使用)
+
+每次 Agent 运行时开启新窗口,避免被上一次的历史消息干扰:
+
+```python
+client = IMClient(
+    contact_id="my_agent",
+    server_url="ws://localhost:8000",
+    window_mode=True  # 自动生成新 chat_id
+)
+# client.chat_id => "20260326_082445_a3f9e1"
+```
+
+恢复上次窗口(如果需要):
+
+```python
+client = IMClient(
+    contact_id="my_agent",
+    window_mode=True,
+    chat_id="20260326_082445_a3f9e1"  # 指定旧窗口
+)
+```
+
+### 窗口模式 vs 普通模式的区别
+
+| | 普通模式 | 窗口模式 |
+|---|---|---|
+| 数据目录 | `data/{contact_id}/` | `data/{contact_id}/windows/{chat_id}/` |
+| 消息隔离 | 所有消息在一起 | 每次运行独立 |
+| 适用场景 | 人类用户、长期运行 | Agent、每次任务独立 |
+
+---
+
+## 自定义通知
+
+当有新消息到达时,Client 会定期调用通知回调。Agent 可以自定义处理方式:
+
+```python
+from notifier import AgentNotifier
+
+class MyAgentNotifier(AgentNotifier):
+    async def notify(self, count: int, from_contacts: list[str]):
+        # count: 新消息条数
+        # from_contacts: 发送者的 contact_id 列表
+        print(f"收到 {count} 条消息,来自 {from_contacts}")
+        # 在这里触发 Agent 的处理逻辑,例如:
+        # - 调用 Agent 的 tool
+        # - 写入 Agent 的任务队列
+        # - 中断当前任务去处理消息
+
+client = IMClient(
+    contact_id="my_agent",
+    notifier=MyAgentNotifier(),
+    notify_interval=10.0  # 每 10 秒检查一次(默认 30 秒)
+)
+```
+
+---
+
+## 文件结构
+
+Client 的所有数据存储在本地文件中:
+
+```
+data/{contact_id}/
+├── chatbox.jsonl          # 所有消息历史(一行一条 JSON)
+├── in_pending.json        # 待处理的收到消息(JSON 数组)
+├── out_pending.jsonl      # 发送失败的消息
+└── windows/               # 窗口模式
+    └── {chat_id}/
+        ├── chatbox.jsonl
+        ├── in_pending.json
+        └── out_pending.jsonl
+```
+
+### chatbox.jsonl 格式
+
+每行一条 JSON,收发消息都在这里:
+
+```jsonl
+{"msg_id":"a1b2c3","sender":"my_agent","receiver":"bob","content":"你好","msg_type":"chat"}
+{"msg_id":"d4e5f6","sender":"bob","receiver":"my_agent","content":"你好!","msg_type":"chat"}
+```
+
+### in_pending.json 格式
+
+JSON 数组,`read_pending()` 调用后清空:
+
+```json
+[
+  {"msg_id":"d4e5f6","sender":"bob","receiver":"my_agent","content":"你好!","msg_type":"chat"}
+]
+```
+
+### out_pending.jsonl 格式
+
+仅记录发送失败的消息(对方不在线、网络断开等),每行一条:
+
+```jsonl
+{"msg_id":"g7h8i9","sender":"my_agent","receiver":"charlie","content":"在吗?","msg_type":"chat"}
+```
+
+---
+
+## 消息协议
+
+所有消息使用统一的 JSON 格式:
+
+```json
+{
+    "msg_id": "a1b2c3d4e5f6",
+    "sender": "my_agent",
+    "receiver": "bob",
+    "content": "消息内容 / 图片URL / 视频URL",
+    "msg_type": "chat"
+}
+```
+
+`msg_type` 可选值:`chat`(文本)、`image`、`video`、`system`。
+
+媒体消息的 `content` 字段填 URL。
+
+---
+
+## 完整示例:Agent 接入
+
+```python
+import asyncio
+import sys
+sys.path.insert(0, "/path/to/IM-Server/im-client")
+
+from client import IMClient
+from notifier import AgentNotifier
+
+
+class MyNotifier(AgentNotifier):
+    def __init__(self, client_ref):
+        self._client_ref = client_ref
+
+    async def notify(self, count, from_contacts):
+        print(f"[IM] {count} 条新消息,来自 {from_contacts}")
+        messages = self._client_ref.read_pending()
+        for msg in messages:
+            # 在这里处理消息
+            print(f"  {msg['sender']}: {msg['content']}")
+
+
+async def main():
+    client = IMClient(
+        contact_id="my_agent",
+        server_url="ws://localhost:8000",
+        window_mode=True,
+        notify_interval=10.0,
+    )
+    client.notifier = MyNotifier(client)
+
+    # 后台启动 client
+    client_task = asyncio.create_task(client.run())
+
+    # 模拟 Agent 工作
+    await asyncio.sleep(2)  # 等待连接建立
+    client.send_message("bob", "你好 Bob,我上线了!")
+
+    # Agent 继续做自己的事...
+    await asyncio.sleep(60)
+
+    client_task.cancel()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
+```

+ 191 - 0
src/im-client/README.md

@@ -0,0 +1,191 @@
+# IM Client - Agent 即时通讯客户端
+
+**Agent 的通信客户端工具**,管理消息历史、联系人、消息收发。
+
+## 概述
+
+IM Client 是每个 Agent 的通信客户端,负责:
+
+- **消息收发**:与 IM Server 通信,发送和接收消息
+- **消息历史**:存储该 Agent 的所有对话消息历史
+- **联系人管理**:管理该 Agent 的联系人列表
+- **消息队列**:管理待处理的消息(特别是数字员工型 Agent)
+
+## 核心功能
+
+- **连接 IM Server**:建立和维护与 IM Server 的连接
+- **发送消息**:发送消息给指定联系人(Agent 或用户)
+- **接收消息**:接收来自其他主体的消息
+- **消息历史存储**:本地存储消息历史(在 Workspace 内)
+- **联系人管理**:维护联系人列表和信息
+
+## 存储位置
+
+IM Client 的数据存储在 Agent 的 Workspace 内:
+
+```
+~/.gateway/workspaces/{workspace_id}/
+├── im_client/
+│   ├── config.json              # IM Client 配置
+│   ├── contacts.json            # 联系人列表
+│   ├── conversations/           # 对话历史
+│   │   ├── {conversation_id}.jsonl
+│   │   └── ...
+│   └── queue/                   # 消息队列(数字员工型)
+│       └── pending.json
+```
+
+## 架构
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                    IM Client                             │
+│                                                          │
+│  ┌────────────────────────────────────────────────────┐ │
+│  │ Connection Manager(连接管理)                      │ │
+│  │ - 连接 IM Server                                    │ │
+│  │ - 心跳保活                                          │ │
+│  │ - 断线重连                                          │ │
+│  └────────────────────────────────────────────────────┘ │
+│                                                          │
+│  ┌────────────────────────────────────────────────────┐ │
+│  │ Message Handler(消息处理)                         │ │
+│  │ - 发送消息                                          │ │
+│  │ - 接收消息                                          │ │
+│  │ - 消息确认                                          │ │
+│  └────────────────────────────────────────────────────┘ │
+│                                                          │
+│  ┌────────────────────────────────────────────────────┐ │
+│  │ Storage Manager(存储管理)                         │ │
+│  │ - 消息历史存储                                      │ │
+│  │ - 联系人管理                                        │ │
+│  │ - 消息队列管理                                      │ │
+│  └────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+                         ↑
+                         │ IM Protocol
+                         │
+                    ┌────▼────┐
+                    │IM Server│
+                    └─────────┘
+```
+
+## 目录结构
+
+```
+im-client/
+├── core/                          # 核心功能
+│   ├── connection.py              # 连接管理
+│   ├── message_handler.py         # 消息处理
+│   └── storage.py                 # 存储管理
+│
+├── api/                           # Python API
+│   └── client.py                  # IMClient 类
+│
+└── docs/                          # 文档
+    ├── architecture.md            # 架构设计
+    ├── api.md                     # API 文档
+    └── storage.md                 # 存储格式文档
+```
+
+## 快速开始
+
+### 创建 IM Client 实例
+
+```python
+from im_client.api.client import IMClient
+
+# 创建 IM Client
+client = IMClient(
+    workspace_path="~/.gateway/workspaces/user_001",
+    im_server_url="http://im-server:8080",
+    agent_id="agent_001"
+)
+
+# 连接 IM Server
+client.connect()
+```
+
+### 发送消息
+
+```python
+# 发送消息给指定联系人
+client.send_message(
+    to="agent_002",
+    text="你好,我需要你的帮助"
+)
+```
+
+### 接收消息
+
+```python
+# 注册消息处理回调
+def on_message(message):
+    print(f"收到消息:{message['text']}")
+    print(f"来自:{message['from']}")
+
+client.on_message(on_message)
+
+# 或者主动查询消息队列(数字员工型)
+messages = client.get_pending_messages()
+for msg in messages:
+    print(msg)
+```
+
+### 查询消息历史
+
+```python
+# 查询与指定联系人的对话历史
+messages = client.get_conversation_history(
+    contact_id="agent_002",
+    limit=50
+)
+
+for msg in messages:
+    print(f"{msg['from']}: {msg['text']}")
+```
+
+### 管理联系人
+
+```python
+# 添加联系人
+client.add_contact(
+    contact_id="agent_002",
+    name="助理 Agent",
+    description="负责数据分析的助理"
+)
+
+# 查询联系人列表
+contacts = client.list_contacts()
+for contact in contacts:
+    print(f"{contact['name']}: {contact['description']}")
+```
+
+## 文档
+
+### 核心文档
+
+- [架构设计](./docs/architecture.md):IM Client 架构和核心模块
+- [API 文档](./docs/api.md):Python API 参考
+- [存储格式](./docs/storage.md):消息历史和联系人的存储格式
+
+## 开发状态
+
+### 待设计 📋
+
+- 架构设计
+- API 设计
+- 存储格式设计
+- IM 协议实现
+
+### 待实现 📋
+
+- 核心代码实现
+- Python API 实现
+- 存储管理实现
+
+## 相关项目
+
+- [IM Server](../im-server/README.md):IM 通信服务器
+- [Gateway](../gateway/README.md):Agent 管理系统
+- [Agent Core](../agent/README.md):Agent 核心框架

+ 308 - 0
src/im-client/client.py

@@ -0,0 +1,308 @@
+import asyncio
+import json
+import logging
+import os
+import tempfile
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+import websockets
+from filelock import FileLock
+
+from protocol import IMMessage, IMResponse
+from notifier import AgentNotifier, ConsoleNotifier
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [CLIENT:%(name)s] %(message)s")
+
+
+class ChatWindow:
+    """单个聊天窗口的数据管理。"""
+
+    def __init__(self, chat_id: str, data_dir: Path):
+        self.chat_id = chat_id
+        self.data_dir = data_dir
+        self.data_dir.mkdir(parents=True, exist_ok=True)
+
+        self.chatbox_path = data_dir / "chatbox.jsonl"
+        self.in_pending_path = data_dir / "in_pending.json"
+        self.out_pending_path = data_dir / "out_pending.jsonl"
+
+        # 文件锁
+        self._in_pending_lock = FileLock(str(data_dir / ".in_pending.lock"))
+        self._out_pending_lock = FileLock(str(data_dir / ".out_pending.lock"))
+        self._chatbox_lock = FileLock(str(data_dir / ".chatbox.lock"))
+
+        # 初始化文件
+        if not self.chatbox_path.exists():
+            self.chatbox_path.write_text("")
+        if not self.in_pending_path.exists():
+            self.in_pending_path.write_text("[]")
+        if not self.out_pending_path.exists():
+            self.out_pending_path.write_text("")
+
+    def append_to_in_pending(self, msg: dict):
+        with self._in_pending_lock:
+            pending = self._load_json_array(self.in_pending_path)
+            pending.append(msg)
+            self._atomic_write_json(self.in_pending_path, pending)
+
+    def read_in_pending(self) -> list[dict]:
+        with self._in_pending_lock:
+            return self._load_json_array(self.in_pending_path)
+
+    def clear_in_pending(self):
+        with self._in_pending_lock:
+            self._atomic_write_json(self.in_pending_path, [])
+
+    def append_to_chatbox(self, msg: dict):
+        with self._chatbox_lock:
+            with open(self.chatbox_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    def append_to_out_pending(self, msg: dict):
+        with self._out_pending_lock:
+            with open(self.out_pending_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    @staticmethod
+    def _load_json_array(path: Path) -> list:
+        if not path.exists():
+            return []
+        text = path.read_text(encoding="utf-8").strip()
+        if not text:
+            return []
+        try:
+            data = json.loads(text)
+            return data if isinstance(data, list) else []
+        except json.JSONDecodeError:
+            return []
+
+    @staticmethod
+    def _atomic_write_json(path: Path, data):
+        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
+        try:
+            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+            os.replace(tmp_path, str(path))
+        except Exception:
+            if os.path.exists(tmp_path):
+                os.unlink(tmp_path)
+            raise
+
+
+class IMClient:
+    """IM Client - 一个实例管理多个聊天窗口。
+
+    一个 Agent (contact_id) 对应一个 IMClient 实例。
+    该实例可以管理多个 chat_id(窗口),每个窗口有独立的消息存储。
+    """
+
+    def __init__(
+        self,
+        contact_id: str,
+        server_url: str = "ws://localhost:8000",
+        data_dir: str | None = None,
+        notify_interval: float = 30.0,
+    ):
+        self.contact_id = contact_id
+        self.server_url = server_url
+        self.notify_interval = notify_interval
+
+        self.base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
+        self.base_dir.mkdir(parents=True, exist_ok=True)
+
+        # 窗口管理
+        self._windows: dict[str, ChatWindow] = {}
+        self._notifiers: dict[str, AgentNotifier] = {}
+
+        self.ws = None
+        self.log = logging.getLogger(contact_id)
+        self._send_queue = asyncio.Queue()
+
+    def open_window(self, chat_id: str | None = None, notifier: AgentNotifier | None = None) -> str:
+        """打开一个新窗口。
+
+        Args:
+            chat_id: 窗口 ID(留空自动生成)
+            notifier: 该窗口的通知器
+
+        Returns:
+            窗口的 chat_id
+        """
+        if chat_id is None:
+            chat_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
+
+        if chat_id in self._windows:
+            return chat_id
+
+        window_dir = self.base_dir / "windows" / chat_id
+        self._windows[chat_id] = ChatWindow(chat_id, window_dir)
+        self._notifiers[chat_id] = notifier or ConsoleNotifier()
+
+        self.log.info(f"打开窗口: {chat_id}")
+        return chat_id
+
+    def close_window(self, chat_id: str):
+        """关闭一个窗口。"""
+        self._windows.pop(chat_id, None)
+        self._notifiers.pop(chat_id, None)
+        self.log.info(f"关闭窗口: {chat_id}")
+
+    def list_windows(self) -> list[str]:
+        """列出所有打开的窗口。"""
+        return list(self._windows.keys())
+
+    async def run(self):
+        """启动 Client 服务,自动重连。"""
+        while True:
+            try:
+                # 连接时不带 chat_id,因为一个实例管理多个窗口
+                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id=__multi__"
+                self.log.info(f"连接 {ws_url} ...")
+                async with websockets.connect(ws_url) as ws:
+                    self.ws = ws
+                    self.log.info("已连接")
+                    await asyncio.gather(
+                        self._ws_listener(),
+                        self._send_worker(),
+                        self._pending_notifier(),
+                    )
+            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
+                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
+                self.ws = None
+                await asyncio.sleep(5)
+            except asyncio.CancelledError:
+                self.log.info("服务停止")
+                break
+
+    async def _ws_listener(self):
+        """监听 WebSocket,根据 receiver_chat_id 分发到对应窗口。"""
+        async for raw in self.ws:
+            try:
+                data = json.loads(raw)
+            except json.JSONDecodeError:
+                self.log.warning(f"收到无效 JSON: {raw}")
+                continue
+
+            if "sender" in data and "receiver" in data:
+                # 聊天消息
+                receiver_chat_id = data.get("receiver_chat_id")
+
+                if receiver_chat_id and receiver_chat_id in self._windows:
+                    # 定向发送到指定窗口
+                    window = self._windows[receiver_chat_id]
+                    window.append_to_in_pending(data)
+                    window.append_to_chatbox(data)
+                    self.log.info(f"收到消息 -> 窗口 {receiver_chat_id}: {data['sender']}")
+                elif not receiver_chat_id:
+                    # 广播到所有窗口
+                    for chat_id, window in self._windows.items():
+                        window.append_to_in_pending(data)
+                        window.append_to_chatbox(data)
+                    self.log.info(f"收到消息 -> 广播到 {len(self._windows)} 个窗口: {data['sender']}")
+                else:
+                    self.log.warning(f"收到消息但窗口 {receiver_chat_id} 不存在")
+
+            elif "status" in data:
+                # 发送回执
+                resp = IMResponse(**data)
+                if resp.status == "success":
+                    self.log.info(f"消息 {resp.msg_id} 发送成功")
+                else:
+                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
+
+    async def _send_worker(self):
+        """从队列取消息并发送。"""
+        while True:
+            msg_data = await self._send_queue.get()
+            msg = IMMessage(sender=self.contact_id, **msg_data)
+            try:
+                await self.ws.send(msg.model_dump_json())
+                self.log.info(f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}")
+                # 记录到发送方窗口的 chatbox
+                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
+                    self._windows[msg.sender_chat_id].append_to_chatbox(msg.model_dump())
+            except Exception as e:
+                self.log.error(f"发送失败: {e}")
+                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
+                    self._windows[msg.sender_chat_id].append_to_out_pending(msg.model_dump())
+
+    async def _pending_notifier(self):
+        """轮询各窗口的 in_pending,有新消息就调通知回调。"""
+        while True:
+            for chat_id, window in list(self._windows.items()):
+                pending = window.read_in_pending()
+                if pending:
+                    senders = list(set(m.get("sender", "unknown") for m in pending))
+                    count = len(pending)
+                    notifier = self._notifiers.get(chat_id)
+                    if notifier:
+                        try:
+                            await notifier.notify(count=count, from_contacts=senders)
+                        except Exception as e:
+                            self.log.error(f"窗口 {chat_id} 通知回调异常: {e}")
+            await asyncio.sleep(self.notify_interval)
+
+    # ── Agent 调用的工具方法 ──
+
+    def read_pending(self, chat_id: str) -> list[dict]:
+        """读取某个窗口的待处理消息,并清空。"""
+        window = self._windows.get(chat_id)
+        if window is None:
+            return []
+        pending = window.read_in_pending()
+        if pending:
+            window.clear_in_pending()
+        return pending
+
+    def send_message(
+        self,
+        chat_id: str,
+        receiver: str,
+        content: str,
+        msg_type: str = "chat",
+        receiver_chat_id: str | None = None,
+    ):
+        """从某个窗口发送消息。"""
+        msg_data = {
+            "sender_chat_id": chat_id,
+            "receiver": receiver,
+            "content": content,
+            "msg_type": msg_type,
+            "receiver_chat_id": receiver_chat_id,
+        }
+        self._send_queue.put_nowait(msg_data)
+
+    def get_chat_history(self, chat_id: str, peer_id: str | None = None, limit: int = 20) -> list[dict]:
+        """查询某个窗口的聊天历史。"""
+        window = self._windows.get(chat_id)
+        if window is None or not window.chatbox_path.exists():
+            return []
+
+        lines = window.chatbox_path.read_text(encoding="utf-8").strip().splitlines()
+        messages = []
+        for line in reversed(lines):
+            if not line.strip():
+                continue
+            try:
+                m = json.loads(line)
+            except json.JSONDecodeError:
+                continue
+
+            if peer_id and m.get("sender") != peer_id and m.get("receiver") != peer_id:
+                continue
+
+            messages.append({
+                "sender": m.get("sender", "unknown"),
+                "receiver": m.get("receiver", "unknown"),
+                "content": m.get("content", ""),
+                "msg_type": m.get("msg_type", "chat"),
+            })
+
+            if len(messages) >= limit:
+                break
+
+        messages.reverse()
+        return messages
+

+ 248 - 0
src/im-client/client.py.bak

@@ -0,0 +1,248 @@
+import asyncio
+import json
+import logging
+import os
+import tempfile
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+import websockets
+from filelock import FileLock
+
+from protocol import IMMessage, IMResponse
+from notifier import AgentNotifier, ConsoleNotifier
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [CLIENT:%(name)s] %(message)s")
+
+
+class IMClient:
+    """IM Client 长驻服务。
+
+    通过 WebSocket 连接 Server,通过文件与 Agent 交互。
+
+    文件约定 (data/{contact_id}/):
+        chatbox.jsonl     — 所有消息历史(收发都记录)
+        in_pending.json   — 收到的待处理消息 (JSON 数组)
+        out_pending.jsonl — 发送失败的消息
+
+    窗口模式 (window_mode=True):
+        每次运行生成新的 chat_id,消息按 chat_id 隔离
+        文件结构变为: data/{contact_id}/{chat_id}/...
+    """
+
+    def __init__(
+        self,
+        contact_id: str,
+        server_url: str = "ws://localhost:8000",
+        data_dir: str | None = None,
+        notifier: AgentNotifier | None = None,
+        notify_interval: float = 30.0,
+        window_mode: bool = False,
+        chat_id: str | None = None,
+    ):
+        self.contact_id = contact_id
+        self.server_url = server_url
+        self.notifier = notifier or ConsoleNotifier()
+        self.notify_interval = notify_interval
+        self.window_mode = window_mode
+
+        # 窗口模式:生成或使用指定的 chat_id
+        base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
+        if window_mode:
+            self.chat_id = chat_id or datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
+            self.data_dir = base_dir / "windows" / self.chat_id
+        else:
+            self.chat_id = None
+            self.data_dir = base_dir
+
+        self.data_dir.mkdir(parents=True, exist_ok=True)
+
+        self.chatbox_path = self.data_dir / "chatbox.jsonl"
+        self.in_pending_path = self.data_dir / "in_pending.json"
+        self.out_pending_path = self.data_dir / "out_pending.jsonl"
+
+        # 文件锁
+        self._in_pending_lock = FileLock(str(self.data_dir / ".in_pending.lock"))
+        self._out_pending_lock = FileLock(str(self.data_dir / ".out_pending.lock"))
+        self._chatbox_lock = FileLock(str(self.data_dir / ".chatbox.lock"))
+
+        self.ws = None
+        self.log = logging.getLogger(f"{contact_id}:{self.chat_id}" if self.chat_id else contact_id)
+        self._send_queue = asyncio.Queue()
+
+        # 初始化文件
+        if not self.chatbox_path.exists():
+            self.chatbox_path.write_text("")
+        if not self.in_pending_path.exists():
+            self.in_pending_path.write_text("[]")
+        if not self.out_pending_path.exists():
+            self.out_pending_path.write_text("")
+
+    async def run(self):
+        """启动 Client 服务,自动重连。"""
+        while True:
+            try:
+                # 构造 WebSocket URL,带上 chat_id 参数
+                chat_id_param = self.chat_id or "default"
+                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id={chat_id_param}"
+                self.log.info(f"连接 {ws_url} ...")
+                async with websockets.connect(ws_url) as ws:
+                    self.ws = ws
+                    self.log.info("已连接")
+                    await asyncio.gather(
+                        self._ws_listener(),
+                        self._send_worker(),
+                        self._pending_notifier(),
+                    )
+            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
+                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
+                self.ws = None
+                await asyncio.sleep(5)
+            except asyncio.CancelledError:
+                self.log.info("服务停止")
+                break
+
+    # ── 协程 1: WebSocket 收消息 ──
+
+    async def _ws_listener(self):
+        """监听 WebSocket,聊天消息写 in_pending 和 chatbox,回执打日志。"""
+        async for raw in self.ws:
+            try:
+                data = json.loads(raw)
+            except json.JSONDecodeError:
+                self.log.warning(f"收到无效 JSON: {raw}")
+                continue
+
+            if "sender" in data and "receiver" in data:
+                # 聊天消息
+                self.log.info(f"收到消息: {data['sender']} -> {data['content'][:50]}")
+                self._append_to_in_pending(data)
+                self._append_to_chatbox(data)
+            elif "status" in data:
+                # 发送回执
+                resp = IMResponse(**data)
+                if resp.status == "success":
+                    self.log.info(f"消息 {resp.msg_id} 发送成功")
+                else:
+                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
+
+    # ── 协程 2: 发送队列处理 ──
+
+    async def _send_worker(self):
+        """从队列取消息并发送,失败则写入 out_pending。"""
+        while True:
+            msg_data = await self._send_queue.get()
+            # 填充 sender_chat_id
+            msg = IMMessage(
+                sender=self.contact_id,
+                sender_chat_id=self.chat_id or "default",
+                **msg_data
+            )
+            try:
+                await self.ws.send(msg.model_dump_json())
+                self.log.info(f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}")
+                # 记录到 chatbox
+                self._append_to_chatbox(msg.model_dump())
+            except Exception as e:
+                self.log.error(f"发送失败: {e}")
+                # 写入 out_pending
+                self._append_to_out_pending(msg.model_dump())
+
+    # ── 协程 3: 轮询 in_pending 通知 Agent ──
+
+    async def _pending_notifier(self):
+        """轮询 in_pending.json,有新消息就调通知回调。"""
+        while True:
+            pending = self._read_in_pending()
+            if pending:
+                senders = list(set(m.get("sender", "unknown") for m in pending))
+                count = len(pending)
+                try:
+                    await self.notifier.notify(count=count, from_contacts=senders)
+                except Exception as e:
+                    self.log.error(f"通知回调异常: {e}")
+            await asyncio.sleep(self.notify_interval)
+
+    # ── 文件操作 (原子性) ──
+
+    def _append_to_in_pending(self, msg: dict):
+        """将收到的消息追加到 in_pending.json。"""
+        with self._in_pending_lock:
+            pending = self._load_json_array(self.in_pending_path)
+            pending.append(msg)
+            self._atomic_write_json(self.in_pending_path, pending)
+
+    def _read_in_pending(self) -> list[dict]:
+        """读取 in_pending.json (不清空)。"""
+        with self._in_pending_lock:
+            return self._load_json_array(self.in_pending_path)
+
+    def _append_to_chatbox(self, msg: dict):
+        """追加消息到 chatbox.jsonl。"""
+        with self._chatbox_lock:
+            with open(self.chatbox_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    def _append_to_out_pending(self, msg: dict):
+        """追加发送失败的消息到 out_pending.jsonl。"""
+        with self._out_pending_lock:
+            with open(self.out_pending_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    # ── Agent 调用的工具方法 ──
+
+    def read_pending(self) -> list[dict]:
+        """Agent 读取 in_pending 中的消息,并清空。"""
+        with self._in_pending_lock:
+            pending = self._load_json_array(self.in_pending_path)
+            if not pending:
+                return []
+            # 清空 in_pending
+            self._atomic_write_json(self.in_pending_path, [])
+            return pending
+
+    def send_message(self, receiver: str, content: str, msg_type: str = "chat", receiver_chat_id: str | None = None):
+        """Agent 调用:将消息放入发送队列。
+
+        Args:
+            receiver: 接收方 contact_id
+            content: 消息内容
+            msg_type: 消息类型
+            receiver_chat_id: 接收方窗口 ID(指定则定向发送,否则广播给该 contact_id 的所有窗口)
+        """
+        msg_data = {
+            "receiver": receiver,
+            "content": content,
+            "msg_type": msg_type,
+            "receiver_chat_id": receiver_chat_id
+        }
+        self._send_queue.put_nowait(msg_data)
+
+    # ── 工具方法 ──
+
+    @staticmethod
+    def _load_json_array(path: Path) -> list:
+        if not path.exists():
+            return []
+        text = path.read_text(encoding="utf-8").strip()
+        if not text:
+            return []
+        try:
+            data = json.loads(text)
+            return data if isinstance(data, list) else []
+        except json.JSONDecodeError:
+            return []
+
+    @staticmethod
+    def _atomic_write_json(path: Path, data):
+        """原子写入:先写临时文件再 rename。"""
+        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
+        try:
+            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+            os.replace(tmp_path, str(path))
+        except Exception:
+            if os.path.exists(tmp_path):
+                os.unlink(tmp_path)
+            raise

+ 22 - 0
src/im-client/notifier.py

@@ -0,0 +1,22 @@
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class AgentNotifier:
+    """Agent 通知接口基类。
+
+    当 pending.json 有新消息时,Client 会调用 notify()。
+    每个 Agent 可以继承此类实现自己的通知方式。
+    """
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        raise NotImplementedError
+
+
+class ConsoleNotifier(AgentNotifier):
+    """默认实现:打印到控制台。"""
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        sources = ", ".join(from_contacts)
+        log.info(f"[IM 通知] 你有 {count} 条新消息,来自: {sources}")

+ 23 - 0
src/im-client/protocol.py

@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+from typing import Optional
+import uuid
+
+
+class IMMessage(BaseModel):
+    msg_id: str = ""
+    sender: str
+    receiver: str
+    content: str
+    msg_type: str = "chat"  # chat | image | video | system
+    sender_chat_id: Optional[str] = None  # 发送方窗口 ID
+    receiver_chat_id: Optional[str] = None  # 接收方窗口 ID(指定则定向,否则广播)
+
+    def model_post_init(self, __context):
+        if not self.msg_id:
+            self.msg_id = uuid.uuid4().hex[:12]
+
+
+class IMResponse(BaseModel):
+    status: str  # "success" | "failed"
+    msg_id: str
+    error: Optional[str] = None

+ 227 - 0
src/im-client/tools.py

@@ -0,0 +1,227 @@
+"""IM Agent Tools — 供 Agent 在 tool-use loop 中调用的工具函数。
+
+新架构:一个 Agent (contact_id) = 一个 IMClient 实例,该实例管理多个窗口 (chat_id)。
+
+使用方式:
+    1. 调用 setup(contact_id) 初始化 Agent 的 IMClient
+    2. 调用 open_window(contact_id, chat_id) 打开窗口
+    3. 在每轮 loop 中调用 check_notification(contact_id, chat_id) 检查该窗口的新消息
+    4. 有通知时调用 receive_messages(contact_id, chat_id) 读取消息
+    5. 发消息调用 send_message(contact_id, chat_id, receiver, content)
+"""
+
+import asyncio
+import httpx
+
+from client import IMClient
+from notifier import AgentNotifier
+
+# ── 全局状态 ──
+
+_clients: dict[str, IMClient] = {}
+_tasks: dict[str, asyncio.Task] = {}
+_notifications: dict[tuple[str, str], dict] = {}  # (contact_id, chat_id) -> 通知
+
+
+class _ToolNotifier(AgentNotifier):
+    """内部通知器:按 (contact_id, chat_id) 分发通知。"""
+
+    def __init__(self, contact_id: str, chat_id: str):
+        self._key = (contact_id, chat_id)
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        _notifications[self._key] = {"count": count, "from": from_contacts}
+
+
+# ── Tool 1: 初始化 Agent ──
+
+def setup(contact_id: str, server_url: str = "ws://localhost:8000", notify_interval: float = 10.0) -> str:
+    """初始化一个 Agent 的 IMClient(一个实例管理多个窗口)。
+
+    Args:
+        contact_id: Agent 的身份 ID
+        server_url: Server 地址
+        notify_interval: 检查新消息的间隔秒数
+
+    Returns:
+        状态描述
+    """
+    if contact_id in _clients:
+        return f"已连接: {contact_id}"
+
+    client = IMClient(contact_id=contact_id, server_url=server_url, notify_interval=notify_interval)
+    _clients[contact_id] = client
+
+    loop = asyncio.get_event_loop()
+    _tasks[contact_id] = loop.create_task(client.run())
+
+    return f"已启动 IM Client: {contact_id}"
+
+
+def teardown(contact_id: str) -> str:
+    """停止并移除一个 Agent 的 IMClient。"""
+    task = _tasks.pop(contact_id, None)
+    if task:
+        task.cancel()
+    _clients.pop(contact_id, None)
+    # 清理该 contact_id 的所有通知
+    keys_to_remove = [k for k in _notifications if k[0] == contact_id]
+    for k in keys_to_remove:
+        _notifications.pop(k, None)
+    return f"已停止: {contact_id}"
+
+
+# ── Tool 2: 窗口管理 ──
+
+def open_window(contact_id: str, chat_id: str | None = None) -> str:
+    """为某个 Agent 打开一个新窗口。
+
+    Args:
+        contact_id: Agent ID
+        chat_id: 窗口 ID(留空自动生成)
+
+    Returns:
+        窗口的 chat_id
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return f"错误: {contact_id} 未初始化"
+
+    actual_chat_id = client.open_window(chat_id=chat_id, notifier=_ToolNotifier(contact_id, chat_id or ""))
+    # 更新 notifier 的 chat_id
+    if chat_id is None:
+        client._notifiers[actual_chat_id] = _ToolNotifier(contact_id, actual_chat_id)
+
+    return actual_chat_id
+
+
+def close_window(contact_id: str, chat_id: str) -> str:
+    """关闭某个窗口。"""
+    client = _clients.get(contact_id)
+    if client is None:
+        return f"错误: {contact_id} 未初始化"
+
+    client.close_window(chat_id)
+    _notifications.pop((contact_id, chat_id), None)
+    return f"已关闭窗口: {chat_id}"
+
+
+def list_windows(contact_id: str) -> list[str]:
+    """列出某个 Agent 的所有窗口。"""
+    client = _clients.get(contact_id)
+    if client is None:
+        return []
+    return client.list_windows()
+
+
+# ── Tool 3: 检查通知 ──
+
+def check_notification(contact_id: str, chat_id: str) -> dict | None:
+    """检查某个窗口是否有新消息通知。
+
+    Returns:
+        有新消息: {"count": 3, "from": ["alice", "bob"]}
+        没有新消息: None
+    """
+    return _notifications.pop((contact_id, chat_id), None)
+
+
+# ── Tool 4: 接收消息 ──
+
+def receive_messages(contact_id: str, chat_id: str) -> list[dict]:
+    """读取某个窗口的待处理消息,读取后自动清空。
+
+    Returns:
+        消息列表,每条格式:
+        {
+            "sender": "alice",
+            "sender_chat_id": "...",
+            "content": "你好",
+            "msg_type": "chat"
+        }
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return []
+
+    raw = client.read_pending(chat_id)
+    return [
+        {
+            "sender": m.get("sender", "unknown"),
+            "sender_chat_id": m.get("sender_chat_id"),
+            "content": m.get("content", ""),
+            "msg_type": m.get("msg_type", "chat"),
+        }
+        for m in raw
+    ]
+
+
+# ── Tool 5: 发送消息 ──
+
+def send_message(
+    contact_id: str,
+    chat_id: str,
+    receiver: str,
+    content: str,
+    msg_type: str = "chat",
+    receiver_chat_id: str | None = None,
+) -> str:
+    """从某个窗口发送消息。
+
+    Args:
+        contact_id: 发送方 Agent ID
+        chat_id: 发送方窗口 ID
+        receiver: 接收方 contact_id
+        content: 消息内容
+        msg_type: 消息类型
+        receiver_chat_id: 接收方窗口 ID(不指定则广播)
+
+    Returns:
+        状态描述
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return f"错误: {contact_id} 未初始化"
+
+    client.send_message(chat_id, receiver, content, msg_type, receiver_chat_id)
+    target = f"{receiver}:{receiver_chat_id}" if receiver_chat_id else f"{receiver}:*"
+    return f"[{contact_id}:{chat_id}] 已发送给 {target}: {content[:50]}"
+
+
+# ── Tool 6: 查询联系人 ──
+
+def get_contacts(contact_id: str, server_http_url: str = "http://localhost:8000") -> dict:
+    """查询某个 Agent 的联系人列表和在线用户。"""
+    if contact_id not in _clients:
+        return {"error": f"{contact_id} 未初始化"}
+
+    result = {}
+    with httpx.Client() as http:
+        try:
+            r = http.get(f"{server_http_url}/contacts/{contact_id}")
+            result["contacts"] = r.json().get("contacts", [])
+        except Exception as e:
+            result["contacts_error"] = str(e)
+
+        try:
+            r = http.get(f"{server_http_url}/health")
+            result["online"] = r.json().get("online", {})
+        except Exception as e:
+            result["online_error"] = str(e)
+
+    return result
+
+
+# ── Tool 7: 查询聊天历史 ──
+
+def get_chat_history(contact_id: str, chat_id: str, peer_id: str | None = None, limit: int = 20) -> list[dict]:
+    """查询某个窗口的聊天历史。"""
+    client = _clients.get(contact_id)
+    if client is None:
+        return []
+
+    return client.get_chat_history(chat_id, peer_id, limit)
+
+
+
+

+ 29 - 11
src/tool_agent/__main__.py

@@ -2,36 +2,54 @@ import asyncio
 import logging
 import sys
 
+from tool_agent.config import settings
 from tool_agent.router.agent import Router
+from tool_agent.service.session import SessionManager
 
-# 配置日志:确保级别是 INFO,这样你能看到 uvicorn 的启动日志
 logging.basicConfig(
-    level=logging.INFO, 
+    level=logging.INFO,
     format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
 )
 logger = logging.getLogger(__name__)
 
+
 async def main():
-    logger.info("正在初始化 Router...")
+    logger.info("正在初始化 Router(RouterAgent)...")
     router = Router()
+
+    logger.info("正在初始化 SessionManager...")
+    session_mgr = SessionManager(router)
+    router.set_session_manager(session_mgr)
+
+    router.create_app(session_manager=session_mgr)
+    port = settings.fastapi_port
+
     try:
-        # 增加超时保护或确保它是非阻塞启动的
-        logger.info("正在启动服务,尝试监听端口 8001...")
-        await router.start(port=8001)
+        # 启动 IM Client(可选,失败不影响 HTTP)
+        try:
+            await session_mgr.start_im(
+                contact_id="tool_agent",
+                server_url="ws://localhost:8000",
+            )
+        except Exception as e:
+            logger.warning(f"IM Client 启动失败(HTTP 不受影响): {e}")
+
+        logger.info(f"正在启动 FastAPI,端口 {port}...")
+        logger.info("对外接口: /health, /tools, /run_tool, /chat")
+        await router.start(port=port)
     except Exception as e:
         logger.error(f"启动失败: {e}")
     finally:
         logger.info("正在停止所有服务...")
+        await session_mgr.stop_im()
         router.stop_all()
 
+
 if __name__ == "__main__":
-    # --- Windows 兼容性补丁 ---
     if sys.platform == 'win32':
-        # 强制使用 ProactorEventLoopPolicy 以支持 Windows 的异步 I/O
         asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
-    
+
     try:
         asyncio.run(main())
     except KeyboardInterrupt:
-        # 优雅处理 Ctrl+C
-        pass
+        logger.info("\n正在关闭...")

BIN
src/tool_agent/__pycache__/__init__.cpython-312.pyc


BIN
src/tool_agent/__pycache__/__main__.cpython-312.pyc


BIN
src/tool_agent/__pycache__/config.cpython-312.pyc


BIN
src/tool_agent/__pycache__/models.cpython-312.pyc


+ 3 - 0
src/tool_agent/config.py

@@ -38,6 +38,9 @@ class Settings(BaseSettings):
     # 健康检查
     health_check_interval_s: int = 60
 
+    # KnowHub API
+    knowhub_api: str = "http://43.106.118.91:9999"
+
     model_config = {"env_prefix": "TOOL_AGENT_"}
 
 

+ 13 - 1
src/tool_agent/models.py

@@ -18,6 +18,13 @@ class ToolStatus(str, Enum):
     BUILDING = "building"
 
 
+class BackendRuntime(str, Enum):
+    """工具后端执行环境(调用层统一为本地 Python/FastAPI)"""
+    LOCAL = "local"    # 本地 Python 运行时(纯 uv 子进程)
+    DOCKER = "docker"  # Docker 容器运行时
+    REMOTE = "remote"  # 云端 API / 远程服务后端
+
+
 class MessageType(str, Enum):
     TOOL_REQUEST = "tool_request"
     TOOL_READY = "tool_ready"
@@ -38,13 +45,18 @@ class ToolMeta(BaseModel):
 
     tool_id: str
     name: str
-    category: str = ""
+    category: str = ""  # 功能领域分类(保留向后兼容)
     description: str = ""
     input_schema: dict[str, Any] = Field(default_factory=dict)
     output_schema: dict[str, Any] = Field(default_factory=dict)
     stream_support: bool = False
     status: ToolStatus = ToolStatus.ACTIVE
 
+    # 新增字段(向后兼容)
+    backend_runtime: BackendRuntime | None = None  # 后端执行环境
+    group_ids: list[str] = Field(default_factory=list)  # 所属工具组
+    tool_slug_ids: list[str] = Field(default_factory=list)  # 关联的 KnowHub 工具 tool_slug 列表
+
 
 # ---- 容器信息(Docker 运行时,独立于工具元数据) ----
 

BIN
src/tool_agent/registry/__pycache__/__init__.cpython-312.pyc


BIN
src/tool_agent/registry/__pycache__/registry.cpython-312.pyc


+ 62 - 0
src/tool_agent/registry/groups.py

@@ -0,0 +1,62 @@
+"""工具组管理 — 管理需要配合使用的工具集合"""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+
+from pydantic import BaseModel, Field
+
+from tool_agent.config import settings
+from tool_agent.models import BackendRuntime
+
+logger = logging.getLogger(__name__)
+
+
+class ToolGroup(BaseModel):
+    """工具组 — 需要配合使用的工具集合"""
+    group_id: str = Field(..., description="工具组 ID")
+    name: str = Field(..., description="工具组名称")
+    description: str = Field(..., description="工具组描述")
+    category: BackendRuntime = Field(..., description="后端执行环境")
+    tool_ids: list[str] = Field(default_factory=list, description="组内工具 ID 列表")
+    usage_order: list[str] | None = Field(None, description="推荐使用顺序")
+    usage_example: str | None = Field(None, description="使用示例")
+
+
+class ToolGroupManager:
+    """工具组管理器"""
+
+    def __init__(self) -> None:
+        self._path = settings.data_dir / "groups.json"
+        self._groups: dict[str, ToolGroup] = {}
+        self._load()
+
+    def _load(self) -> None:
+        """加载工具组配置"""
+        if self._path.exists():
+            try:
+                data = json.loads(self._path.read_text(encoding="utf-8"))
+                for item in data.get("groups", []):
+                    group = ToolGroup(**item)
+                    self._groups[group.group_id] = group
+                logger.info(f"Loaded {len(self._groups)} tool groups")
+            except Exception as e:
+                logger.error(f"Failed to load groups: {e}")
+
+    def get_group(self, group_id: str) -> ToolGroup | None:
+        """获取指定工具组"""
+        return self._groups.get(group_id)
+
+    def list_all(self) -> list[ToolGroup]:
+        """列出所有工具组"""
+        return list(self._groups.values())
+
+    def list_by_category(self, category: BackendRuntime) -> list[ToolGroup]:
+        """按分类列出工具组"""
+        return [g for g in self._groups.values() if g.category == category]
+
+    def find_groups_for_tool(self, tool_id: str) -> list[ToolGroup]:
+        """查找包含指定工具的所有工具组"""
+        return [g for g in self._groups.values() if tool_id in g.tool_ids]

+ 139 - 8
src/tool_agent/registry/registry.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 import logging
 import shutil
@@ -10,11 +11,66 @@ import threading
 from pathlib import Path
 
 from tool_agent.config import settings
-from tool_agent.models import ToolMeta, ToolStatus
+from tool_agent.models import ToolMeta, ToolStatus, BackendRuntime
 
 logger = logging.getLogger(__name__)
 
 
+class RegistryWriteQueue:
+    """Registry 写入队列,保证并发安全"""
+
+    def __init__(self):
+        self._queue: asyncio.Queue = None
+        self._worker_task = None
+        self._running = False
+
+    async def start(self):
+        """启动写入队列"""
+        if self._running:
+            return
+        self._queue = asyncio.Queue()
+        self._running = True
+        self._worker_task = asyncio.create_task(self._worker())
+        logger.info("Registry write queue started")
+
+    async def stop(self):
+        """停止写入队列"""
+        if not self._running:
+            return
+        self._running = False
+        await self._queue.put(None)  # 发送停止信号
+        if self._worker_task:
+            await self._worker_task
+        logger.info("Registry write queue stopped")
+
+    async def enqueue(self, operation: str, data: dict):
+        """将写入操作加入队列"""
+        if not self._running:
+            await self.start()
+        await self._queue.put({"operation": operation, "data": data})
+
+    async def _worker(self):
+        """后台工作线程,顺序处理写入操作"""
+        while self._running:
+            try:
+                item = await self._queue.get()
+                if item is None:  # 停止信号
+                    break
+
+                operation = item["operation"]
+                data = item["data"]
+
+                # 执行写入操作
+                if operation == "save":
+                    callback = data.get("callback")
+                    if callback:
+                        callback()
+
+                self._queue.task_done()
+            except Exception as e:
+                logger.error(f"Registry write queue error: {e}")
+
+
 class ToolRegistry:
     """工具注册表 — 只管工具是什么,不管在哪跑"""
 
@@ -22,20 +78,60 @@ class ToolRegistry:
         self._path = settings.data_dir / "registry.json"
         self._tools: dict[str, ToolMeta] = {}
         self._lock = threading.Lock()
+        self._write_queue = RegistryWriteQueue()
+
+        # 工具组管理器
+        from tool_agent.registry.groups import ToolGroupManager
+        self.group_manager = ToolGroupManager()
+
         self._load()
 
     def _load(self) -> None:
-        if self._path.exists():
-            data = json.loads(self._path.read_text(encoding="utf-8"))
-            for item in data.get("tools", []):
-                tool = ToolMeta(**item)
-                self._tools[tool.tool_id] = tool
+        with self._lock:
+            self._tools = {}
+            if self._path.exists():
+                data = json.loads(self._path.read_text(encoding="utf-8"))
+                for item in data.get("tools", []):
+                    tool = ToolMeta(**item)
+                    if tool.backend_runtime is None:
+                        tool.backend_runtime = self._infer_backend_runtime(tool.tool_id)
+                    if not tool.group_ids:
+                        tool.group_ids = [g.group_id for g in self.group_manager.find_groups_for_tool(tool.tool_id)]
+                    self._tools[tool.tool_id] = tool
 
     def _save(self) -> None:
+        """同步保存(内部使用,已加锁)"""
         self._path.parent.mkdir(parents=True, exist_ok=True)
         data = {"tools": [t.model_dump(mode="json") for t in self._tools.values()], "version": "2.0"}
         self._path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
 
+    async def _save_async(self) -> None:
+        """异步保存(通过队列)"""
+        await self._write_queue.enqueue("save", {"callback": self._save_sync})
+
+    def _save_sync(self) -> None:
+        """同步保存回调"""
+        with self._lock:
+            self._save()
+
+    def _infer_backend_runtime(self, tool_id: str) -> BackendRuntime:
+        """根据 source 推断后端执行环境"""
+        source_store_path = settings.data_dir / "sources.json"
+        if source_store_path.exists():
+            try:
+                data = json.loads(source_store_path.read_text(encoding="utf-8"))
+                for source in data.get("sources", {}).get(tool_id, []):
+                    stype = source.get("type", "")
+                    if stype == "docker":
+                        return BackendRuntime.DOCKER
+                    if stype == "local":
+                        return BackendRuntime.LOCAL
+                    if stype in {"hub", "remote"}:
+                        return BackendRuntime.REMOTE
+            except Exception:
+                pass
+        return BackendRuntime.LOCAL
+
     # ---- CRUD ----
 
     def get(self, tool_id: str) -> ToolMeta | None:
@@ -47,7 +143,17 @@ class ToolRegistry:
     def register(self, tool: ToolMeta) -> None:
         with self._lock:
             self._tools[tool.tool_id] = tool
-            self._save()
+        # 异步保存,避免阻塞
+        try:
+            loop = asyncio.get_event_loop()
+            if loop.is_running():
+                asyncio.create_task(self._save_async())
+            else:
+                self._save()
+        except RuntimeError:
+            # 没有事件循环,同步保存
+            with self._lock:
+                self._save()
         logger.info(f"Registered tool: {tool.tool_id}")
 
     def unregister(self, tool_id: str) -> bool:
@@ -62,7 +168,16 @@ class ToolRegistry:
     def update(self, tool: ToolMeta) -> None:
         with self._lock:
             self._tools[tool.tool_id] = tool
-            self._save()
+        # 异步保存
+        try:
+            loop = asyncio.get_event_loop()
+            if loop.is_running():
+                asyncio.create_task(self._save_async())
+            else:
+                self._save()
+        except RuntimeError:
+            with self._lock:
+                self._save()
 
     # ---- 查询 ----
 
@@ -70,8 +185,24 @@ class ToolRegistry:
         return [t for t in self._tools.values() if t.status == ToolStatus.ACTIVE]
 
     def find_by_category(self, category: str) -> list[ToolMeta]:
+        """按功能分类查找(保留向后兼容)"""
         return [t for t in self._tools.values() if t.category == category and t.status == ToolStatus.ACTIVE]
 
+    def find_by_backend_runtime(self, backend_runtime: str) -> list[ToolMeta]:
+        """按后端执行环境查找"""
+        try:
+            br = BackendRuntime(backend_runtime)
+            return [t for t in self._tools.values() if t.backend_runtime == br and t.status == ToolStatus.ACTIVE]
+        except ValueError:
+            return []
+
+    def get_tools_in_group(self, group_id: str) -> list[ToolMeta]:
+        """获取工具组内的所有工具"""
+        group = self.group_manager.get_group(group_id)
+        if not group:
+            return []
+        return [self._tools[tid] for tid in group.tool_ids if tid in self._tools and self._tools[tid].status == ToolStatus.ACTIVE]
+
     def search(self, keyword: str) -> list[ToolMeta]:
         kw = keyword.lower()
         return [

BIN
src/tool_agent/router/__pycache__/__init__.cpython-312.pyc


BIN
src/tool_agent/router/__pycache__/agent.cpython-312.pyc


BIN
src/tool_agent/router/__pycache__/dispatcher.cpython-312.pyc


BIN
src/tool_agent/router/__pycache__/server.cpython-312.pyc


BIN
src/tool_agent/router/__pycache__/status.cpython-312.pyc


+ 172 - 9
src/tool_agent/router/agent.py

@@ -1,7 +1,7 @@
 """Router — 路由层入口
 
-直接启动 FastAPI 服务,不依赖 MessageBus / Agent 抽象
-管理工具状态表,对外暴露 search_tools / select_tool / create_tool 接口。
+管理工具注册表、状态表、请求分发
+对外通过 FastAPI 暴露 /health, /tools, /run_tool, /chat 接口。
 """
 
 from __future__ import annotations
@@ -12,7 +12,6 @@ from typing import Any
 
 from tool_agent.registry.registry import ToolRegistry
 from tool_agent.router.dispatcher import Dispatcher
-from tool_agent.router.server import create_app
 from tool_agent.router.status import ToolStatusManager
 
 logger = logging.getLogger(__name__)
@@ -26,7 +25,16 @@ class Router:
         self.status_manager = ToolStatusManager(self.registry)
         self.dispatcher = Dispatcher(self.status_manager)
         self._tasks: dict[str, dict[str, Any]] = {}
-        self.app = create_app(self)
+        self._coding_agent_tasks: dict[str, asyncio.Task] = {}
+        self._session_manager = None  # 由 __main__ 注入
+
+    def set_session_manager(self, sm) -> None:
+        self._session_manager = sm
+
+    def create_app(self, session_manager=None):
+        from tool_agent.router.server import create_app
+        self.app = create_app(self, session_manager=session_manager)
+        return self.app
 
     async def start(self, port: int = 8001) -> None:
         import uvicorn
@@ -35,25 +43,180 @@ class Router:
         logger.info(f"Router starting on port {port}")
         await server.serve()
 
-    def submit_create_task(self, task_id: str, task_spec: str) -> None:
-        self._tasks[task_id] = {"status": "pending", "task_spec": task_spec}
-        asyncio.get_event_loop().create_task(self._run_create_task(task_id, task_spec))
+    def submit_create_task(self, task_id: str, task_spec: str, reference_files: list[str] = None) -> None:
+        self._tasks[task_id] = {"status": "pending", "task_spec": task_spec, "reference_files": reference_files or []}
+        task = asyncio.get_event_loop().create_task(self._run_create_task(task_id, task_spec, reference_files or []))
+        self._coding_agent_tasks[task_id] = task
 
-    async def _run_create_task(self, task_id: str, task_spec: str) -> None:
+    async def _run_create_task(self, task_id: str, task_spec: str, reference_files: list[str]) -> None:
         self._tasks[task_id]["status"] = "running"
         try:
+            from pathlib import Path
             from tool_agent.tool.agent import CodingAgent
+
+            # 读取 reference_files 内容
+            ref_files_dict = {}
+            for file_path in reference_files:
+                try:
+                    p = Path(file_path)
+                    if p.exists():
+                        ref_files_dict[file_path] = p.read_text(encoding="utf-8", errors="replace")
+                except Exception as e:
+                    logger.warning(f"Failed to read reference file {file_path}: {e}")
+
             agent = CodingAgent()
-            result = await agent.execute(task_spec)
+            result = await agent.execute(task_spec, reference_files=ref_files_dict if ref_files_dict else None)
             self._tasks[task_id].update({"status": "completed", "result": result})
             self.registry._load()
             self.status_manager._sync_from_registry()
+
+            # CodingAgent 完成后,同步点亮 KnowHub 工具表
+            await self._sync_knowhub_after_register()
         except Exception as e:
             logger.error(f"Create task {task_id} failed: {e}")
             self._tasks[task_id].update({"status": "failed", "error": str(e)})
+        finally:
+            self._coding_agent_tasks.pop(task_id, None)
 
     def get_task_status(self, task_id: str) -> dict[str, Any] | None:
         return self._tasks.get(task_id)
 
+    def get_coding_agent_status(self) -> dict[str, Any]:
+        running = [tid for tid, task in self._coding_agent_tasks.items() if not task.done()]
+        tasks_summary = []
+        for tid, data in list(self._tasks.items())[-10:]:
+            tasks_summary.append({
+                "task_id": tid,
+                "status": data.get("status"),
+                "task_spec": data.get("task_spec", "")[:120],
+                "error": data.get("error"),
+            })
+        return {
+            "running_count": len(running),
+            "running_task_ids": running,
+            "queued_count": len([t for t in self._tasks.values() if t.get("status") == "pending"]),
+            "recent_tasks": tasks_summary,
+        }
+
+    def get_system_status(self) -> dict[str, Any]:
+        """全局状态:工具、CodingAgent、ServiceAgent 窗口"""
+        status = {
+            "tools": {
+                "total": len(self.registry.list_all()),
+                "active": len([t for t in self.registry.list_all() if t.status.value == "active"]),
+            },
+            "coding_agent": self.get_coding_agent_status(),
+            "service_agent": (
+                self._session_manager.get_status()
+                if self._session_manager else {"total_windows": 0, "im_connected": False}
+            ),
+        }
+        return status
+
     def stop_all(self) -> int:
         return self.status_manager.stop_all()
+
+    async def _sync_knowhub_after_register(self) -> None:
+        """CodingAgent 注册完工具后,智能匹配 KnowHub 工具表并点亮
+
+        1. 获取所有 registry 工具和 KnowHub 工具
+        2. 根据名称、描述、分类等信息智能匹配
+        3. 更新双向索引:registry.tool_slug_ids 和 KnowHub.toolhub_items
+        4. 点亮已接入的 KnowHub 工具
+        """
+        try:
+            from tool_agent.tool_table import get_client
+            from tool_agent.registry.groups import ToolGroupManager
+
+            client = get_client()
+            group_manager = ToolGroupManager()
+            all_knowhub_tools = client.list_all_tools()
+
+            if not all_knowhub_tools:
+                logger.info("KnowHub tool table is empty or unreachable, skipping sync")
+                return
+
+            # 构建 KnowHub 工具索引
+            knowhub_index = {}  # tool_slug -> knowhub_tool
+            for kt in all_knowhub_tools:
+                slug = kt.get("metadata", {}).get("tool_slug", "")
+                if slug:
+                    knowhub_index[slug] = kt
+
+            # 遍历 registry 工具,智能匹配 KnowHub 工具
+            matched_map: dict[str, dict] = {}  # slug -> {"tools": [...], "groups": set()}
+
+            for tool in self.registry.list_all():
+                matched_slugs = self._match_knowhub_tools(tool, knowhub_index)
+
+                if matched_slugs:
+                    # 更新 registry 中的 tool_slug_ids
+                    if set(matched_slugs) != set(tool.tool_slug_ids):
+                        tool.tool_slug_ids = matched_slugs
+                        self.registry.update(tool)
+                        logger.info(f"Updated {tool.tool_id} -> {matched_slugs}")
+
+                    # 收集到 matched_map
+                    for slug in matched_slugs:
+                        if slug not in matched_map:
+                            matched_map[slug] = {"tools": [], "groups": set()}
+                        matched_map[slug]["tools"].append({tool.tool_id: tool.description})
+                        matched_map[slug]["groups"].update(tool.group_ids)
+
+            # 更新 KnowHub 工具表
+            for slug, data in matched_map.items():
+                kt = knowhub_index.get(slug)
+                if not kt:
+                    continue
+
+                # 合并工具和组
+                items = data["tools"].copy()
+                for group_id in data["groups"]:
+                    group = group_manager.get_group(group_id)
+                    if group:
+                        items.append({group_id: group.description})
+
+                try:
+                    client.update_toolhub_items(kt["id"], items)
+                    client.update_tool_status(kt["id"], "已接入")
+                    logger.info(f"Synced {slug}: {len(data['tools'])} tools, {len(data['groups'])} groups")
+                except Exception as e:
+                    logger.warning(f"Failed to sync {slug}: {e}")
+
+        except Exception as e:
+            logger.warning(f"KnowHub sync failed (non-fatal): {e}")
+
+    def _match_knowhub_tools(self, tool, knowhub_index: dict) -> list[str]:
+        """智能匹配工具能解决哪些 KnowHub 工具问题
+
+        匹配规则:
+        1. 工具名称包含 KnowHub 工具的 slug 或 title
+        2. 工具描述包含 KnowHub 工具的关键词
+        3. 分类匹配
+        """
+        matched = []
+        tool_name_lower = tool.name.lower()
+        tool_id_lower = tool.tool_id.lower()
+        tool_desc_lower = tool.description.lower()
+
+        for slug, kt in knowhub_index.items():
+            kt_slug = slug.lower()
+            kt_title = kt.get("title", "").lower()
+            kt_desc = kt.get("metadata", {}).get("description", "").lower()
+
+            # 规则1: 名称匹配
+            if kt_slug in tool_name_lower or kt_slug in tool_id_lower:
+                matched.append(slug)
+                continue
+            if kt_title and kt_title in tool_name_lower:
+                matched.append(slug)
+                continue
+
+            # 规则2: 描述关键词匹配(提取 KnowHub 工具的核心词)
+            if kt_desc:
+                keywords = [w for w in kt_desc.split() if len(w) > 3][:5]
+                if any(kw in tool_desc_lower for kw in keywords):
+                    matched.append(slug)
+                    continue
+
+        return matched

+ 867 - 0
src/tool_agent/router/router_agent.py

@@ -0,0 +1,867 @@
+"""Router Agent — 基于 claude_agent_sdk 的智能路由代理
+
+负责:
+1. 理解用户需求,撰写任务书
+2. 调度 CodingAgent 创建工具
+3. 搜索和调用已有工具
+4. 提供命令行交互界面
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import TYPE_CHECKING
+
+from claude_agent_sdk import (
+    AssistantMessage, UserMessage, ResultMessage,
+    TextBlock, ClaudeAgentOptions, tool,
+    create_sdk_mcp_server, ClaudeSDKClient, ThinkingBlock, ToolUseBlock,
+)
+
+if TYPE_CHECKING:
+    from tool_agent.router.agent import Router
+
+logger = logging.getLogger(__name__)
+
+
+# ===========================================================================
+#  Router Agent 工具定义
+# ===========================================================================
+
+@tool(name="search_tools", description="""
+搜索已注册的工具列表。
+
+Args:
+    keyword: 搜索关键词(可选),匹配工具名称或描述
+    category: 工具分类(可选),如 "cv", "nlp", "devtools"
+
+Returns:
+    工具列表,包含 tool_id、名称、描述、参数、运行状态等
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "keyword": {"type": "string", "description": "搜索关键词"},
+        "category": {"type": "string", "description": "工具分类"},
+    },
+})
+async def search_tools_fn(args):
+    """搜索工具"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    all_tools = _router_instance.registry.list_all()
+    matched = all_tools
+
+    if args.get("category"):
+        matched = [t for t in matched if t.category == args["category"]]
+    if args.get("keyword"):
+        kw = args["keyword"].lower()
+        matched = [t for t in matched if kw in t.name.lower() or kw in t.description.lower()]
+
+    tools_info = []
+    for tool in matched:
+        status = _router_instance.status_manager.get_status(tool.tool_id)
+
+        # 解析参数列表
+        params = []
+        if tool.input_schema and "properties" in tool.input_schema:
+            required_fields = tool.input_schema.get("required", [])
+            for param_name, param_def in tool.input_schema["properties"].items():
+                params.append({
+                    "name": param_name,
+                    "type": param_def.get("type", "string"),
+                    "required": param_name in required_fields,
+                    "description": param_def.get("description", ""),
+                })
+
+        tools_info.append({
+            "tool_id": tool.tool_id,
+            "name": tool.name,
+            "description": tool.description,
+            "category": tool.category,
+            "backend_runtime": tool.backend_runtime.value if tool.backend_runtime else "unknown",
+            "group_ids": tool.group_ids,
+            "state": status.state.value if status else "unknown",
+            "params": params,
+        })
+
+    return _text_result({
+        "status": "success",
+        "total": len(tools_info),
+        "tools": tools_info,
+    })
+
+
+@tool(name="call_tool", description="""
+调用已注册的工具。如果工具未启动,会自动启动。
+
+Args:
+    tool_id: 工具 ID
+    params: 工具参数(JSON 对象)
+
+Returns:
+    工具执行结果
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "tool_id": {"type": "string", "description": "工具 ID"},
+        "params": {"type": "object", "description": "工具参数"},
+    },
+    "required": ["tool_id", "params"],
+})
+async def call_tool_fn(args):
+    """调用工具"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    tool_id = args["tool_id"]
+    params = args.get("params", {})
+
+    try:
+        result = await _router_instance.dispatcher.dispatch(tool_id, params)
+        return _text_result({"status": "success", "result": result})
+    except Exception as e:
+        return _text_result({"status": "error", "error": str(e)})
+
+
+@tool(name="create_tool", description="""
+创建新工具。根据用户需求撰写详细任务书,提交给 CodingAgent 异步执行。
+
+**重要**:你需要先理解用户需求,然后撰写一个详细的任务书(task_spec)。
+
+任务书必须包含:
+1. 功能描述
+2. 输入输出的 JSON Schema
+3. 实现要求(使用 uv 还是 Docker)
+4. tool_id(小写字母+下划线)
+5. 明确要求"必须注册到 Router"
+
+Args:
+    tool_id: 工具 ID(小写字母+下划线),如 "image_compressor"
+    description: 简短功能描述
+    task_spec: 详细任务书(包含功能需求、接口定义、实现要求)
+    reference_files: 参考文件路径列表(可选)
+
+Returns:
+    task_id 和状态
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "tool_id": {"type": "string", "description": "工具 ID"},
+        "description": {"type": "string", "description": "简短功能描述"},
+        "task_spec": {"type": "string", "description": "详细任务书"},
+        "reference_files": {"type": "array", "items": {"type": "string"}, "description": "参考文件路径"},
+    },
+    "required": ["tool_id", "description", "task_spec"],
+})
+async def create_tool_fn(args):
+    """创建工具"""
+    import uuid
+
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    task_id = f"create_{uuid.uuid4().hex[:8]}"
+    task_spec = args.get("task_spec") or args["description"]
+    reference_files = args.get("reference_files", [])
+
+    _router_instance.submit_create_task(task_id, task_spec, reference_files)
+
+    return _text_result({
+        "status": "success",
+        "task_id": task_id,
+        "message": "Task submitted, use check_task_status to monitor progress",
+    })
+
+
+@tool(name="check_task_status", description="""
+查询异步任务状态。
+
+Args:
+    task_id: 任务 ID
+
+Returns:
+    任务状态(pending/running/completed/failed)和结果
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "task_id": {"type": "string", "description": "任务 ID"},
+    },
+    "required": ["task_id"],
+})
+async def check_task_status_fn(args):
+    """查询任务状态"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    task = _router_instance.get_task_status(args["task_id"])
+    if not task:
+        return _text_result({"status": "error", "error": "Task not found"})
+
+    return _text_result(task)
+
+
+@tool(name="list_all_tools", description="""
+列出所有已注册的工具,包含详细信息。
+
+Returns:
+    所有工具的列表,包含 tool_id、名称、描述、状态、参数等
+""", input_schema={
+    "type": "object",
+    "properties": {},
+})
+async def list_all_tools_fn(args):
+    """列出所有工具"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    all_tools = _router_instance.registry.list_all()
+    tools_info = []
+
+    for tool in all_tools:
+        status = _router_instance.status_manager.get_status(tool.tool_id)
+        tools_info.append({
+            "tool_id": tool.tool_id,
+            "name": tool.name,
+            "description": tool.description,
+            "category": tool.category,
+            "backend_runtime": tool.backend_runtime.value if tool.backend_runtime else "unknown",
+            "group_ids": tool.group_ids,
+            "state": status.state.value if status else "unknown",
+            "port": status.port if status else None,
+        })
+
+    return _text_result({
+        "status": "success",
+        "total": len(tools_info),
+        "tools": tools_info,
+    })
+
+
+@tool(name="get_tool_details", description="""
+获取指定工具的详细信息,包括参数定义、输出格式、运行状态等。
+
+Args:
+    tool_id: 工具 ID
+
+Returns:
+    工具的完整信息
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "tool_id": {"type": "string", "description": "工具 ID"},
+    },
+    "required": ["tool_id"],
+})
+async def get_tool_details_fn(args):
+    """获取工具详细信息"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    tool = _router_instance.registry.get(args["tool_id"])
+    if not tool:
+        return _text_result({"status": "error", "error": f"Tool '{args['tool_id']}' not found"})
+
+    status = _router_instance.status_manager.get_status(args["tool_id"])
+
+    return _text_result({
+        "status": "success",
+        "tool": {
+            "tool_id": tool.tool_id,
+            "name": tool.name,
+            "description": tool.description,
+            "category": tool.category,
+            "backend_runtime": tool.backend_runtime.value if tool.backend_runtime else "unknown",
+            "group_ids": tool.group_ids,
+            "input_schema": tool.input_schema,
+            "output_schema": tool.output_schema,
+            "stream_support": tool.stream_support,
+            "state": status.state.value if status else "unknown",
+            "port": status.port if status else None,
+            "pid": status.pid if status else None,
+        }
+    })
+
+
+@tool(name="get_tools_status", description="""
+获取所有工具的运行状态。
+
+Returns:
+    所有工具的状态信息(运行中/已停止/错误等)
+""", input_schema={
+    "type": "object",
+    "properties": {},
+})
+async def get_tools_status_fn(args):
+    """获取工具状态"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    statuses = _router_instance.status_manager.list_status()
+    status_info = []
+
+    for status in statuses:
+        status_info.append({
+            "tool_id": status.tool_id,
+            "state": status.state,
+            "port": status.port,
+            "pid": status.pid,
+            "last_error": status.last_error,
+        })
+
+    return _text_result({
+        "status": "success",
+        "total": len(status_info),
+        "tools": status_info,
+    })
+
+
+@tool(name="list_backend_runtimes", description="""
+列出所有后端执行环境类型(local/docker/remote)及其说明。
+
+注意:所有工具对外都是统一的本地 Python/FastAPI 调用层,backend_runtime 表示工具内部的执行目标。
+
+Returns:
+    后端运行时列表,包含名称、描述、工具数量
+""", input_schema={
+    "type": "object",
+    "properties": {},
+})
+async def list_backend_runtimes_fn(args):
+    """列出后端执行环境"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    runtimes = [
+        {
+            "backend_runtime": "local",
+            "name": "本地 Python 运行时",
+            "description": "使用 uv 管理的本地 Python 进程,启动快、资源占用少,适合纯 Python 任务",
+            "tool_count": len(_router_instance.registry.find_by_backend_runtime("local")),
+        },
+        {
+            "backend_runtime": "docker",
+            "name": "Docker 容器运行时",
+            "description": "运行在 Docker 容器中,适合需要系统库、GPU 或隔离环境的场景",
+            "tool_count": len(_router_instance.registry.find_by_backend_runtime("docker")),
+        },
+        {
+            "backend_runtime": "remote",
+            "name": "远程 API / 云端服务",
+            "description": "调用外部 API 或云端服务,如 RunComfy、LiblibAI 等第三方平台",
+            "tool_count": len(_router_instance.registry.find_by_backend_runtime("remote")),
+        },
+    ]
+
+    return _text_result({
+        "status": "success",
+        "total": len(runtimes),
+        "backend_runtimes": runtimes,
+    })
+
+
+@tool(name="list_groups", description="""
+列出所有工具组。
+
+Returns:
+    所有工具组的列表,包含组名、描述、工具列表和使用顺序
+""", input_schema={
+    "type": "object",
+    "properties": {},
+})
+async def list_groups_fn(args):
+    """列出工具组"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    groups = _router_instance.registry.group_manager.list_all()
+    group_info = []
+
+    for group in groups:
+        group_info.append({
+            "group_id": group.group_id,
+            "name": group.name,
+            "description": group.description,
+            "category": group.category.value,
+            "tool_ids": group.tool_ids,
+            "usage_order": group.usage_order,
+            "usage_example": group.usage_example,
+        })
+
+    return _text_result({
+        "status": "success",
+        "total": len(group_info),
+        "groups": group_info,
+    })
+
+
+@tool(name="get_group_details", description="""
+获取指定工具组的详细信息。
+
+Args:
+    group_id: 工具组 ID
+
+Returns:
+    工具组的完整信息
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "group_id": {"type": "string", "description": "工具组 ID"},
+    },
+    "required": ["group_id"],
+})
+async def get_group_details_fn(args):
+    """获取工具组详细信息"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    group = _router_instance.registry.group_manager.get_group(args["group_id"])
+    if not group:
+        return _text_result({"status": "error", "error": f"Group '{args['group_id']}' not found"})
+
+    tools = _router_instance.registry.get_tools_in_group(args["group_id"])
+
+    return _text_result({
+        "status": "success",
+        "group": {
+            "group_id": group.group_id,
+            "name": group.name,
+            "description": group.description,
+            "category": group.category.value,
+            "tool_ids": group.tool_ids,
+            "usage_order": group.usage_order,
+            "usage_example": group.usage_example,
+            "tools": [
+                {
+                    "tool_id": t.tool_id,
+                    "name": t.name,
+                    "description": t.description,
+                }
+                for t in tools
+            ],
+        }
+    })
+
+
+@tool(name="list_tasks", description="""
+列出所有异步任务及其状态。
+
+Returns:
+    所有任务的列表
+""", input_schema={
+    "type": "object",
+    "properties": {},
+})
+async def list_tasks_fn(args):
+    """列出所有任务"""
+    if not _router_instance:
+        return _text_result({"status": "error", "error": "Router not initialized"})
+
+    tasks = []
+    for task_id, task_data in _router_instance._tasks.items():
+        tasks.append({
+            "task_id": task_id,
+            "status": task_data.get("status"),
+            "task_spec": task_data.get("task_spec", "")[:100],  # 只显示前100字符
+        })
+
+    return _text_result({
+        "status": "success",
+        "total": len(tasks),
+        "tasks": tasks,
+    })
+
+
+# ===========================================================================
+#  辅助函数
+# ===========================================================================
+
+def _text_result(data: dict) -> dict:
+    """将 dict 包装为 claude_agent_sdk 的 tool 返回格式"""
+    return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False, indent=2)}]}
+
+
+def _build_dynamic_context(router: "Router") -> str:
+    """构造 Router Agent 的动态系统上下文"""
+    runtimes = [
+        {
+            "backend_runtime": "local",
+            "name": "本地 Python 运行时",
+            "description": "使用 uv 管理的本地 Python 进程,启动快、资源占用少,适合纯 Python 任务",
+            "tool_count": len(router.registry.find_by_backend_runtime("local")),
+        },
+        {
+            "backend_runtime": "docker",
+            "name": "Docker 容器运行时",
+            "description": "运行在 Docker 容器中,适合需要系统库、GPU 或隔离环境的场景",
+            "tool_count": len(router.registry.find_by_backend_runtime("docker")),
+        },
+        {
+            "backend_runtime": "remote",
+            "name": "远程 API / 云端服务",
+            "description": "调用外部 API 或云端服务,如 RunComfy、LiblibAI 等第三方平台",
+            "tool_count": len(router.registry.find_by_backend_runtime("remote")),
+        },
+    ]
+
+    groups = []
+    for g in router.registry.group_manager.list_all():
+        groups.append({
+            "group_id": g.group_id,
+            "name": g.name,
+            "description": g.description,
+            "category": g.category.value,
+            "tool_ids": g.tool_ids,
+            "usage_order": g.usage_order,
+        })
+
+    tools = []
+    for tool in router.registry.list_all():
+        route = router.status_manager.get_status(tool.tool_id)
+        state = route.state.value if route else "stopped"
+        tools.append({
+            "tool_id": tool.tool_id,
+            "name": tool.name,
+            "description": tool.description,
+            "category": tool.category,
+            "backend_runtime": tool.backend_runtime.value if tool.backend_runtime else "unknown",
+            "group_ids": tool.group_ids,
+            "state": state,
+            "port": route.port if route else None,
+        })
+
+    coding_status = router.get_coding_agent_status()
+    tasks = [
+        {"task_id": tid, "status": data.get("status"), "task_spec": data.get("task_spec", "")[:120]}
+        for tid, data in list(router._tasks.items())[-10:]
+    ]
+
+    return json.dumps({
+        "backend_runtimes": runtimes,
+        "groups": groups,
+        "tools": tools,
+        "coding_agent": coding_status,
+        "recent_tasks": tasks,
+    }, ensure_ascii=False, indent=2)
+
+
+# ===========================================================================
+#  Router Agent System Prompt
+# ===========================================================================
+
+ROUTER_AGENT_SYSTEM_PROMPT = """你是 Router Agent,Tool Agent 系统的智能客服和工具管理员。
+
+**重要:你不是 Claude,你是 Router Agent。请始终以 Router Agent 的身份回答问题。**
+
+## 你的角色定位
+
+你是一个**友好、专业的客服**,负责:
+1. **帮助用户使用工具**:解答工具使用问题、调用工具、处理错误
+2. **管理工具库**:搜索、推荐、调用已有工具
+3. **创建新工具**:理解需求、撰写任务书、调度 CodingAgent
+4. **提供技术支持**:解决接口问题、参数错误、调用失败等
+
+## 你拥有的工具
+
+**工具管理:**
+- `search_tools`: 搜索工具(支持关键词和分类)
+- `list_all_tools`: 列出所有工具
+- `get_tool_details`: 获取工具详细信息(参数、输出格式等)
+- `get_tools_status`: 获取所有工具的运行状态
+- `list_backend_runtimes`: 列出后端执行环境类型及说明
+- `list_groups`: 列出固定配合使用的工具组
+- `get_group_details`: 获取工具组详细信息和推荐使用顺序
+
+**工具操作:**
+- `call_tool`: 调用已注册的工具
+- `create_tool`: 创建新工具(提交任务书给 CodingAgent)
+
+**任务管理:**
+- `list_tasks`: 列出所有异步任务
+- `check_task_status`: 查询任务状态
+
+## 工作流程
+
+### 1. 用户咨询工具使用
+**场景**:用户询问"如何使用 XXX 工具"、"XXX 工具的参数是什么"
+**处理**:
+- 使用 `search_tools` 查找工具
+- 详细说明工具的功能、参数、使用方法
+- 提供示例调用
+
+### 2. 用户请求调用工具
+**场景**:用户说"帮我压缩这张图片"、"用 XXX 工具处理"
+**处理**:
+- 确认工具 ID 和参数
+- 使用 `call_tool` 调用工具
+- 返回结果或错误信息
+- 如果失败,提供解决方案
+
+### 3. 用户请求创建工具
+**场景**:用户说"我需要一个 XXX 工具"、"能不能做 XXX"
+**处理**:
+1. 先使用 `search_tools` 搜索是否已有类似工具
+2. 如果没有,询问详细需求:
+   - 输入是什么?(文件、文本、URL?)
+   - 输出是什么?(文件、JSON、图片?)
+   - 有什么特殊要求?
+3. **撰写详细任务书**(这是最重要的步骤):
+   ```
+   ## 目标
+   创建一个 HTTP API 工具并注册到 Router,用于 [功能描述]
+
+   ## 核心功能
+   [详细功能说明]
+
+   ## HTTP API 接口(必须实现)
+   实现 POST /[endpoint] 接口:
+
+   ### 输入 JSON Schema
+   {
+     "type": "object",
+     "properties": {
+       "param1": {"type": "string", "description": "参数说明"},
+       ...
+     },
+     "required": ["param1"]
+   }
+
+   ### 输出 JSON Schema
+   {
+     "type": "object",
+     "properties": {
+       "result": {"type": "string", "description": "结果说明"},
+       ...
+     }
+   }
+
+   ## 实现要求
+   1. 使用 uv 创建项目,项目名:[tool_id]
+   2. 使用 FastAPI 实现 HTTP 接口
+   3. 添加必要的依赖包
+   4. 编写测试脚本验证功能
+   5. **必须调用 register_tool 注册到 Router**,tool_id 为 "[tool_id]"
+   ```
+4. 使用 `create_tool` 提交任务
+5. 告知用户 task_id,可以用 `check_task_status` 查询进度
+
+### 4. 用户遇到问题
+**场景**:用户说"调用失败"、"参数错误"、"工具不工作"
+**处理**:
+- 询问具体错误信息
+- 检查工具状态
+- 提供解决方案(修正参数、重启工具、重新创建)
+- 如果是工具 bug,记录并提交修复任务
+
+## 撰写任务书的要点
+
+当需要创建新工具时,任务书必须包含:
+
+```json
+{
+  "description": "简短描述(一句话)",
+  "task_spec": "详细任务书,包含:
+    1. 功能需求:明确输入输出
+    2. 接口定义:JSON Schema 格式
+    3. 实现要求:
+       - 使用 uv 项目(纯 Python)或 Docker(需要系统库/GPU)
+       - 项目名称和 tool_id
+       - 依赖包列表
+    4. 交付要求:
+       - 创建 uv 项目
+       - 编写核心业务逻辑
+       - 编写 FastAPI HTTP 接口
+       - 调用 register_tool 注册到 Router
+       - 验证工具可用性
+  ",
+  "reference_files": ["相关参考文件路径"]
+}
+```
+
+## 重要规则
+
+1. **优先使用已有工具**:避免重复创建
+2. **优先使用工具组视角回答**:当多个工具固定配合使用时,优先介绍工具组、步骤顺序和参数传递关系
+3. **回答时基于实时状态**:如果动态上下文里有工具状态、任务状态、Coding Agent 状态,优先基于这些实时信息回答
+4. **友好沟通**:用自然语言,不要太技术化
+5. **主动询问**:需求不明确时主动询问细节
+6. **提供帮助**:遇到错误时提供解决方案,不要只说"失败了"
+7. **记录问题**:如果是系统 bug,记录并提交修复任务
+8. **耐心解答**:用户可能不熟悉技术细节,耐心解释
+
+## 对话风格
+
+- 友好、专业、耐心
+- 用简单的语言解释技术概念
+- 提供具体的示例和步骤
+- 遇到问题时提供解决方案,不要只说"不行"
+- 适当使用 emoji 让对话更友好(但不要过度)
+"""
+
+
+# ===========================================================================
+#  Router Agent 类
+# ===========================================================================
+
+ALL_ROUTER_TOOLS = [
+    search_tools_fn,
+    call_tool_fn,
+    create_tool_fn,
+    check_task_status_fn,
+    list_all_tools_fn,
+    get_tool_details_fn,
+    get_tools_status_fn,
+    list_backend_runtimes_fn,
+    list_groups_fn,
+    get_group_details_fn,
+    list_tasks_fn,
+]
+
+MCP_SERVER_NAME = "router_tools"
+
+
+class RouterAgent:
+    """Router Agent:智能路由代理,负责理解需求、调度工具"""
+
+    def __init__(self, router: Router, model: str = "claude-sonnet-4-5") -> None:
+        self.router = router
+        self.model = model
+
+        # 设置全局 router 实例供工具函数使用
+        global _router_instance
+        _router_instance = router
+
+    async def chat(self, user_message: str) -> str:
+        """单轮对话"""
+        mcp_server = create_sdk_mcp_server(
+            name=MCP_SERVER_NAME,
+            version="1.0.0",
+            tools=ALL_ROUTER_TOOLS,
+        )
+
+        dynamic_context = _build_dynamic_context(self.router)
+        enhanced_message = f"""[系统提示:你是 Router Agent,Tool Agent 系统的智能客服]
+
+[实时系统上下文]
+{dynamic_context}
+
+用户消息:{user_message}"""
+
+        options = ClaudeAgentOptions(
+            system_prompt=ROUTER_AGENT_SYSTEM_PROMPT,
+            mcp_servers={MCP_SERVER_NAME: mcp_server},
+            model=self.model,
+            setting_sources=["project"],
+            allowed_tools=[
+                f"mcp__{MCP_SERVER_NAME}__{t.name}"
+                for t in ALL_ROUTER_TOOLS
+            ]
+        )
+
+        result_text = ""
+
+        async with ClaudeSDKClient(options=options) as client:
+            await client.query(enhanced_message)
+
+            async for message in client.receive_response():
+                if isinstance(message, AssistantMessage):
+                    for block in message.content:
+                        if isinstance(block, TextBlock):
+                            logger.info(f"[ROUTER] {block.text}")
+                            result_text = block.text
+                        elif isinstance(block, ThinkingBlock):
+                            logger.debug(f"[THINKING] {block.thinking[:200]}...")
+                        elif isinstance(block, ToolUseBlock):
+                            logger.info(f"[TOOL_USE] {block.name}")
+
+                elif isinstance(message, ResultMessage):
+                    logger.info(f"[DONE] duration={message.duration_ms}ms")
+                    if message.result:
+                        result_text = message.result
+
+        return result_text
+
+    async def interactive_loop(self):
+        """交互式命令行循环"""
+        print("=" * 60)
+        print("Router Agent - Interactive Mode")
+        print("=" * 60)
+        print("Commands:")
+        print("  - Type your request to interact with tools")
+        print("  - Type 'exit' or 'quit' to stop")
+        print("  - Type 'help' for available commands")
+        print("-" * 60)
+
+        while True:
+            try:
+                user_input = await asyncio.to_thread(input, "\n[You] > ")
+                user_input = user_input.strip()
+
+                if not user_input:
+                    continue
+
+                if user_input.lower() in ("exit", "quit"):
+                    print("Goodbye!")
+                    break
+
+                if user_input.lower() == "help":
+                    print("\nAvailable commands:")
+                    print("  search <keyword>  - Search for tools")
+                    print("  list              - List all tools")
+                    print("  status            - Show tools status")
+                    print("  runtimes          - List backend runtimes")
+                    print("  groups            - List tool groups")
+                    print("  exit/quit         - Exit interactive mode")
+                    continue
+
+                # 处理简单命令
+                if user_input.lower() == "list":
+                    tools = self.router.registry.list_all()
+                    print(f"\nTotal tools: {len(tools)}")
+                    for t in tools:
+                        print(f"  - {t.tool_id}: {t.name}")
+                    continue
+
+                if user_input.lower() == "status":
+                    tools = self.router.registry.list_all()
+                    print(f"\nTools status:")
+                    for t in tools:
+                        status = self.router.status_manager.get_status(t.tool_id)
+                        state = status.state.value if status else "unknown"
+                        print(f"  - {t.tool_id}: {state}")
+                    continue
+
+                if user_input.lower() == "runtimes":
+                    print("\nBackend runtimes:")
+                    ctx = json.loads(_build_dynamic_context(self.router))
+                    for item in ctx["backend_runtimes"]:
+                        print(f"  - {item['category']}: {item['name']} ({item['tool_count']})")
+                        print(f"    {item['description']}")
+                    continue
+
+                if user_input.lower() == "groups":
+                    print("\nTool groups:")
+                    for item in json.loads(_build_dynamic_context(self.router))["groups"]:
+                        print(f"  - {item['group_id']}: {item['name']}")
+                        print(f"    tools={item['tool_ids']}")
+                    continue
+
+                # 使用 Claude 处理复杂请求
+                print("\n[Router Agent] Processing...")
+                response = await self.chat(user_input)
+                print(f"\n[Router Agent] {response}")
+
+            except KeyboardInterrupt:
+                print("\n\nInterrupted. Type 'exit' to quit.")
+            except EOFError:
+                print("\nGoodbye!")
+                break
+            except Exception as e:
+                logger.error(f"Error in interactive loop: {e}")
+                print(f"\nError: {e}")
+
+
+# 全局 router 实例(供工具函数使用)
+_router_instance: Router | None = None

+ 103 - 153
src/tool_agent/router/server.py

@@ -1,8 +1,14 @@
-"""FastAPI 应用定义 — 对外三个核心接口"""
+"""FastAPI 应用定义 — 对外接口
+
+对外仅暴露 4 个接口:
+1. GET  /health     — 健康检查
+2. GET  /tools      — 查看完整工具表(含分类、工具组、状态)
+3. POST /run_tool   — 调用工具
+4. POST /chat       — 与 ServiceAgent 对话(客服入口)
+"""
 
 from __future__ import annotations
 
-import uuid
 import logging
 from typing import Any, TYPE_CHECKING
 
@@ -11,182 +17,126 @@ from pydantic import BaseModel, Field
 
 if TYPE_CHECKING:
     from tool_agent.router.agent import Router
+    from tool_agent.service.session import SessionManager
 
 logger = logging.getLogger(__name__)
 
 
 # ---- 请求/响应模型 ----
 
-class SearchToolsRequest(BaseModel):
-    keyword: str = ""
-    category: str = ""
-
-class ToolParamInfo(BaseModel):
-    name: str
-    type: str = ""
-    description: str = ""
-    required: bool = False
-    default: Any = None
-    enum: list[Any] | None = None
-
-class ToolInfo(BaseModel):
-    tool_id: str
-    name: str
-    description: str
-    category: str
-    stream_support: bool = False
-    # 解析后的参数列表(方便直接阅读)
-    params: list[ToolParamInfo] = Field(default_factory=list)
-    required_params: list[str] = Field(default_factory=list)
-    # 原始 schema(供程序使用)
-    input_schema: dict[str, Any] = Field(default_factory=dict)
-    output_schema: dict[str, Any] = Field(default_factory=dict)
-    # 运行时信息
-    runtime_type: str = ""
-    host_dir: str = ""
-    endpoint_path: str = ""
-    http_method: str = ""
-    state: str = "stopped"
-    port: int | None = None
-    pid: int | None = None
-
-class SearchToolsResponse(BaseModel):
-    tools: list[ToolInfo]
-    total: int
-
-class SelectToolRequest(BaseModel):
+class RunToolRequest(BaseModel):
     tool_id: str
     params: dict[str, Any] = Field(default_factory=dict)
-    stream: bool = False
 
-class SelectToolResponse(BaseModel):
-    status: str = "success"
+class RunToolResponse(BaseModel):
+    status: str
     result: Any = None
     error: str | None = None
 
-class CreateToolRequest(BaseModel):
-    description: str
-    task_spec: str = ""
+class ChatRequest(BaseModel):
+    message: str
+    chat_id: str  # 必填,每个对话窗口一个
+
+class ChatResponse(BaseModel):
+    response: str
+    chat_id: str
 
-class CreateToolResponse(BaseModel):
-    task_id: str
-    status: str = "pending"
-    message: str = ""
 
+def create_app(router: Router, session_manager: SessionManager = None) -> FastAPI:
+    app = FastAPI(title="Tool Agent", version="2.0")
 
-def create_app(router: Router) -> FastAPI:
-    app = FastAPI(title="Tool Agent Router", version="0.1.0")
+    # ---- 1. 健康检查 ----
 
     @app.get("/health")
     async def health():
         return {"status": "ok"}
 
-    # ---- 1. 搜索工具 ----
-
-    @app.post("/search_tools", response_model=SearchToolsResponse)
-    async def search_tools(request: SearchToolsRequest):
-        """搜索可用工具列表,返回工具信息及运行状态"""
-        all_tools = router.registry.list_all()
-
-        matched = all_tools
-        if request.category:
-            matched = [t for t in matched if t.category == request.category]
-        if request.keyword:
-            kw = request.keyword.lower()
-            matched = [t for t in matched if kw in t.name.lower() or kw in t.description.lower()]
-
-        result = []
-        for tool in matched:
+    # ---- 2. 查看完整工具表 ----
+
+    @app.get("/tools")
+    async def list_tools():
+        """返回完整工具表:按后端执行环境分类,包含工具组和运行状态"""
+
+        backend_runtimes = [
+            {
+                "backend_runtime": "local",
+                "name": "本地 Python 运行时",
+                "description": "使用 uv 管理的本地 Python 进程,启动快、资源占用少",
+            },
+            {
+                "backend_runtime": "docker",
+                "name": "Docker 容器运行时",
+                "description": "运行在 Docker 容器中,适合需要系统库、GPU 或隔离环境的场景",
+            },
+            {
+                "backend_runtime": "remote",
+                "name": "远程 API / 云端服务",
+                "description": "调用外部 API 或云端服务,如 RunComfy、LiblibAI 等",
+            },
+        ]
+
+        groups = []
+        for g in router.registry.group_manager.list_all():
+            groups.append({
+                "group_id": g.group_id,
+                "name": g.name,
+                "description": g.description,
+                "backend_runtime": g.category.value,
+                "tool_ids": g.tool_ids,
+                "usage_order": g.usage_order,
+                "usage_example": g.usage_example,
+            })
+
+        tools = []
+        for tool in router.registry.list_all():
+            source = router.status_manager.get_primary_source(tool.tool_id)
             route = router.status_manager.get_status(tool.tool_id)
-            source = route.sources[route.active_source] if route and route.sources else None
-
-            # 解析 input_schema 为可读参数列表
-            params = []
-            required_names = tool.input_schema.get("required", [])
-            for pname, pdef in tool.input_schema.get("properties", {}).items():
-                params.append(ToolParamInfo(
-                    name=pname,
-                    type=pdef.get("type", pdef.get("$ref", "")),
-                    description=pdef.get("description", ""),
-                    required=pname in required_names,
-                    default=pdef.get("default"),
-                    enum=pdef.get("enum"),
-                ))
-
-            result.append(ToolInfo(
-                tool_id=tool.tool_id,
-                name=tool.name,
-                description=tool.description,
-                category=tool.category,
-                stream_support=tool.stream_support,
-                params=params,
-                required_params=required_names,
-                input_schema=tool.input_schema,
-                output_schema=tool.output_schema,
-                runtime_type=source.type.value if source else "unknown",
-                host_dir=source.host_dir if source else "",
-                endpoint_path=source.endpoint_path if source else "",
-                http_method=source.http_method if source else "",
-                state=route.state.value if route else "stopped",
-                port=route.port if route else None,
-                pid=route.pid if route else None,
-            ))
-
-        return SearchToolsResponse(tools=result, total=len(result))
-
-    # ---- 2. 选择并调用工具 ----
-
-    @app.post("/select_tool", response_model=SelectToolResponse)
-    async def select_tool(request: SelectToolRequest):
-        """选择工具并调用:未启动则自动启动"""
-        tool = router.registry.get(request.tool_id)
-        if not tool:
-            raise HTTPException(status_code=404, detail=f"Tool '{request.tool_id}' not found")
-
-        proc = router.status_manager.get_status(request.tool_id)
-        if not proc or proc.state != "running":
-            proc = router.status_manager.start_tool(request.tool_id)
-            if proc.state != "running":
-                return SelectToolResponse(status="error", error=f"Failed to start tool: {proc.last_error}")
-
+            tools.append({
+                "tool_id": tool.tool_id,
+                "name": tool.name,
+                "description": tool.description,
+                "category": tool.category,
+                "backend_runtime": tool.backend_runtime.value if tool.backend_runtime else "unknown",
+                "group_ids": tool.group_ids,
+                "input_schema": tool.input_schema,
+                "output_schema": tool.output_schema,
+                "state": route.state.value if route else "stopped",
+                "port": route.port if route else None,
+            })
+
+        return {
+            "backend_runtimes": backend_runtimes,
+            "groups": groups,
+            "tools": tools,
+            "total": len(tools),
+        }
+
+    # ---- 3. 调用工具 ----
+
+    @app.post("/run_tool")
+    async def run_tool(request: RunToolRequest):
+        """调用已注册的工具"""
         try:
-            result = await router.dispatcher.dispatch(request.tool_id, request.params, request.stream)
-            return SelectToolResponse(status="success", result=result)
+            result = await router.dispatcher.dispatch(request.tool_id, request.params)
+            return RunToolResponse(status="success", result=result)
         except Exception as e:
-            return SelectToolResponse(status="error", error=str(e))
+            logger.error(f"run_tool failed: {e}")
+            return RunToolResponse(status="error", error=str(e))
 
-    # ---- 3. 请求创建新工具 ----
+    # ---- 4. ServiceAgent 对话 ----
 
-    @app.post("/create_tool", response_model=CreateToolResponse)
-    async def create_tool(request: CreateToolRequest):
-        """提交新工具创建需求,异步交给 CodingAgent"""
-        task_id = f"create_{uuid.uuid4().hex[:8]}"
-        task_spec = request.task_spec or request.description
+    @app.post("/chat", response_model=ChatResponse)
+    async def chat(request: ChatRequest):
+        """与 ServiceAgent 对话。每个 chat_id 对应一个独立的客服实例。"""
+        if session_manager is None:
+            raise HTTPException(status_code=503, detail="SessionManager not initialized")
 
-        router.submit_create_task(task_id, task_spec)
-
-        return CreateToolResponse(task_id=task_id, status="pending", message="Task submitted")
-
-    # ---- 辅助接口(仅 localhost) ----
-
-    @app.get("/tools/status")
-    async def tools_status():
-        statuses = router.status_manager.list_status()
-        return {"tools": [s.model_dump() for s in statuses]}
-
-    @app.post("/tools/{tool_id}/start")
-    async def start_tool(tool_id: str):
-        return router.status_manager.start_tool(tool_id).model_dump()
-
-    @app.post("/tools/{tool_id}/stop")
-    async def stop_tool(tool_id: str):
-        return router.status_manager.stop_tool(tool_id).model_dump()
-
-    @app.get("/tasks/{task_id}")
-    async def get_task(task_id: str):
-        task = router.get_task_status(task_id)
-        if not task:
-            raise HTTPException(status_code=404, detail="Task not found")
-        return task
+        try:
+            response, chat_id = await session_manager.chat(
+                request.message, chat_id=request.chat_id)
+            return ChatResponse(response=response, chat_id=chat_id)
+        except Exception as e:
+            logger.error(f"Chat error: {e}")
+            raise HTTPException(status_code=500, detail=str(e))
 
     return app

+ 11 - 7
src/tool_agent/router/status.py

@@ -33,7 +33,8 @@ logger = logging.getLogger(__name__)
 class SourceType(str, Enum):
     LOCAL = "local"       # 本地 uv 项目
     DOCKER = "docker"     # Docker 容器
-    HUB = "hub"           # 外部 API Hub(枚举内部原子工具)
+    REMOTE = "remote"     # 远程 API / 云端服务
+    HUB = "hub"           # 兼容旧数据(等价于 remote)
 
 
 class ToolSource(BaseModel):
@@ -44,10 +45,13 @@ class ToolSource(BaseModel):
     # docker
     container_id: str = ""
     image: str = ""
-    # hub
-    hub_url: str = ""                      # Hub 的基础 URL
-    hub_tool_path: str = ""                # Hub 内该原子工具的路径
-    hub_api_key: str = ""
+    # remote / hub
+    remote_url: str = ""                   # 远程服务基础 URL
+    remote_path: str = ""                  # 远程服务内工具路径
+    remote_api_key: str = ""               # 远程服务 API Key
+    hub_url: str = ""                      # 兼容旧数据
+    hub_tool_path: str = ""                # 兼容旧数据
+    hub_api_key: str = ""                  # 兼容旧数据
     # 通用
     endpoint_path: str = "/"               # HTTP API 路径
     http_method: str = "POST"
@@ -180,8 +184,8 @@ class ToolStatusManager:
             return self._start_local(tool_id, source)
         elif source.type == SourceType.DOCKER:
             return self._check_docker(tool_id, source)
-        elif source.type == SourceType.HUB:
-            # Hub 工具不需要启动本地进程,直接标记 running
+        elif source.type == SourceType.HUB or source.type == SourceType.REMOTE:
+            # 远程工具不需要启动本地进程,直接标记 running
             route.state = ProcessState.RUNNING
             with self._lock:
                 self._routes[tool_id] = route

+ 184 - 0
src/tool_agent/router/tool_table_tools.py

@@ -0,0 +1,184 @@
+"""Router Agent 工具表管理工具
+
+提供给 Router Agent 调用的工具表查询和管理功能。
+"""
+
+from typing import Any
+from tool_agent.tool_table import get_client
+
+
+def query_knowhub_tools(category: str | None = None, keyword: str | None = None) -> dict[str, Any]:
+    """查询 KnowHub 工具表
+
+    Args:
+        category: 工具分类(可选):image_gen, image_process, model, plugin, workflow, other
+        keyword: 关键词搜索(可选)
+
+    Returns:
+        查询结果,包含工具列表和统计信息
+    """
+    client = get_client()
+
+    if category:
+        tools = client.search_tools_by_category(category)
+    else:
+        tools = client.list_all_tools()
+
+    # 关键词过滤
+    if keyword:
+        keyword_lower = keyword.lower()
+        tools = [
+            t for t in tools
+            if keyword_lower in t.get("title", "").lower()
+            or keyword_lower in t.get("metadata", {}).get("description", "").lower()
+            or keyword_lower in t.get("metadata", {}).get("tool_slug", "").lower()
+        ]
+
+    return {
+        "status": "success",
+        "total": len(tools),
+        "tools": [
+            {
+                "tool_id": t.get("id"),
+                "title": t.get("title"),
+                "category": t.get("metadata", {}).get("category"),
+                "status": t.get("metadata", {}).get("status"),
+                "description": t.get("metadata", {}).get("description"),
+                "knowledge_ids": t.get("metadata", {}).get("knowledge_ids", [])
+            }
+            for t in tools
+        ]
+    }
+
+
+def get_knowhub_tool_detail(tool_id: str) -> dict[str, Any]:
+    """获取 KnowHub 工具详情
+
+    Args:
+        tool_id: 工具 ID,格式:tools/{category}/{slug}
+
+    Returns:
+        工具详情
+    """
+    client = get_client()
+    tool = client.get_tool(tool_id)
+
+    if not tool:
+        return {
+            "status": "error",
+            "message": f"Tool {tool_id} not found"
+        }
+
+    metadata = tool.get("metadata", {})
+    return {
+        "status": "success",
+        "tool": {
+            "tool_id": tool.get("id"),
+            "title": tool.get("title"),
+            "category": metadata.get("category"),
+            "tool_slug": metadata.get("tool_slug"),
+            "status": metadata.get("status"),
+            "version": metadata.get("version"),
+            "description": metadata.get("description"),
+            "usage": metadata.get("usage"),
+            "scenarios": metadata.get("scenarios", []),
+            "input": metadata.get("input"),
+            "output": metadata.get("output"),
+            "source": metadata.get("source"),
+            "knowledge_ids": metadata.get("knowledge_ids", []),
+            "toolhub_items": metadata.get("toolhub_items", []),
+            "backend_runtime": metadata.get("backend_runtime")
+        }
+    }
+
+
+def sync_tool_to_knowhub(
+    tool_id: str,
+    name: str,
+    category: str,
+    description: str,
+    backend_runtime: str = "local"
+) -> dict[str, Any]:
+    """同步 Tool Agent 工具到 KnowHub
+
+    Args:
+        tool_id: Tool Agent 中的工具 ID
+        name: 工具名称
+        category: 工具分类
+        description: 工具描述
+        backend_runtime: 后端运行时(local/docker/remote)
+
+    Returns:
+        同步结果
+    """
+    client = get_client()
+    success = client.sync_tool_from_registry(
+        tool_id=tool_id,
+        name=name,
+        category=category,
+        description=description,
+        backend_runtime=backend_runtime
+    )
+
+    if success:
+        return {
+            "status": "success",
+            "message": f"Tool {tool_id} synced to KnowHub",
+            "knowhub_tool_id": f"tools/{category}/{tool_id}"
+        }
+    else:
+        return {
+            "status": "error",
+            "message": f"Failed to sync tool {tool_id} to KnowHub"
+        }
+
+
+def update_knowhub_tool_status(tool_id: str, new_status: str) -> dict[str, Any]:
+    """更新 KnowHub 工具状态
+
+    Args:
+        tool_id: 工具 ID,格式:tools/{category}/{slug}
+        new_status: 新状态(未接入/已接入/测试中)
+
+    Returns:
+        更新结果
+    """
+    client = get_client()
+    success = client.update_tool_status(tool_id, new_status)
+
+    if success:
+        return {
+            "status": "success",
+            "message": f"Tool {tool_id} status updated to '{new_status}'"
+        }
+    else:
+        return {
+            "status": "error",
+            "message": f"Failed to update tool {tool_id} status"
+        }
+
+
+def update_knowhub_toolhub_items(tool_id: str, toolhub_items: list[dict[str, str]]) -> dict[str, Any]:
+    """更新 KnowHub 工具关联的 Tool Agent 工具列表
+
+    Args:
+        tool_id: KnowHub 工具 ID,格式:tools/{category}/{slug}
+        toolhub_items: Tool Agent 工具列表,每项为 {tool_id: name}
+            示例: [{"launch_comfy_env": "启动 ComfyUI 环境"}, {"runcomfy_stop_env": "停止 ComfyUI 环境"}]
+
+    Returns:
+        更新结果
+    """
+    client = get_client()
+    success = client.update_toolhub_items(tool_id, toolhub_items)
+
+    if success:
+        return {
+            "status": "success",
+            "message": f"Tool {tool_id} toolhub_items updated ({len(toolhub_items)} items)"
+        }
+    else:
+        return {
+            "status": "error",
+            "message": f"Failed to update toolhub_items for {tool_id}"
+        }

BIN
src/tool_agent/runtime/__pycache__/__init__.cpython-312.pyc


BIN
src/tool_agent/runtime/__pycache__/docker_runner.cpython-312.pyc


BIN
src/tool_agent/runtime/__pycache__/local_runner.cpython-312.pyc


+ 16 - 13
src/tool_agent/runtime/local_runner.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 import logging
 import subprocess
@@ -19,21 +20,23 @@ class LocalRunner:
 
     def __init__(self) -> None:
         self._background_procs: list[subprocess.Popen] = []
+        self._proc_lock = asyncio.Lock()  # 进程列表锁
 
-    def cleanup_background(self) -> int:
+    async def cleanup_background(self) -> int:
         """杀掉所有后台进程,返回清理数量"""
-        killed = 0
-        for proc in self._background_procs:
-            try:
-                proc.kill()
-                proc.wait(timeout=5)
-                killed += 1
-            except Exception:
-                pass
-        self._background_procs.clear()
-        if killed:
-            logger.info(f"Cleaned up {killed} background processes")
-        return killed
+        async with self._proc_lock:
+            killed = 0
+            for proc in self._background_procs:
+                try:
+                    proc.kill()
+                    proc.wait(timeout=5)
+                    killed += 1
+                except Exception:
+                    pass
+            self._background_procs.clear()
+            if killed:
+                logger.info(f"Cleaned up {killed} background processes")
+            return killed
 
     # ---- 环境创建与管理 ----
 

+ 1 - 0
src/tool_agent/service/__init__.py

@@ -0,0 +1 @@
+"""service — 客服 Agent 和会话管理"""

+ 241 - 0
src/tool_agent/service/agent.py

@@ -0,0 +1,241 @@
+"""ServiceAgent — 只读客服 Agent
+
+职责:
+1. 查询工具表、工具组、后端运行时、工具状态
+2. 回答外部 Agent 关于工具使用的问题
+3. 撰写任务书提交给 CodingAgent(唯一的写操作)
+4. 不直接调用工具、不启停工具
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from claude_agent_sdk import (
+    AssistantMessage, TextBlock, ClaudeAgentOptions, tool,
+    create_sdk_mcp_server, ClaudeSDKClient, ToolUseBlock,
+)
+
+if TYPE_CHECKING:
+    from tool_agent.router.agent import Router
+
+logger = logging.getLogger(__name__)
+
+MCP_SERVER_NAME = "service_tools"
+_router: Router | None = None
+
+
+def _result(data: dict) -> dict:
+    return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False, indent=2)}]}
+
+
+# ===========================================================================
+#  只读工具
+# ===========================================================================
+
+@tool(name="list_tools", description="列出所有已注册工具的摘要。",
+      input_schema={"type": "object", "properties": {}})
+async def list_tools_fn(args):
+    tools = []
+    for t in _router.registry.list_all():
+        route = _router.status_manager.get_status(t.tool_id)
+        tools.append({
+            "tool_id": t.tool_id, "name": t.name,
+            "description": t.description, "category": t.category,
+            "backend_runtime": t.backend_runtime.value if t.backend_runtime else "unknown",
+            "group_ids": t.group_ids,
+            "state": route.state.value if route else "stopped",
+        })
+    return _result({"total": len(tools), "tools": tools})
+
+
+@tool(name="get_tool_details", description="获取指定工具的详细信息。",
+      input_schema={"type": "object", "properties": {
+          "tool_id": {"type": "string"}}, "required": ["tool_id"]})
+async def get_tool_details_fn(args):
+    t = _router.registry.get(args["tool_id"])
+    if not t:
+        return _result({"error": f"Tool '{args['tool_id']}' not found"})
+    route = _router.status_manager.get_status(t.tool_id)
+    return _result({
+        "tool_id": t.tool_id, "name": t.name,
+        "description": t.description, "category": t.category,
+        "backend_runtime": t.backend_runtime.value if t.backend_runtime else "unknown",
+        "group_ids": t.group_ids,
+        "input_schema": t.input_schema, "output_schema": t.output_schema,
+        "state": route.state.value if route else "stopped",
+    })
+
+
+@tool(name="list_groups", description="列出所有工具组。",
+      input_schema={"type": "object", "properties": {}})
+async def list_groups_fn(args):
+    groups = []
+    for g in _router.registry.group_manager.list_all():
+        groups.append({
+            "group_id": g.group_id, "name": g.name,
+            "description": g.description, "category": g.category.value,
+            "tool_ids": g.tool_ids, "usage_order": g.usage_order,
+            "usage_example": g.usage_example,
+        })
+    return _result({"total": len(groups), "groups": groups})
+
+
+@tool(name="list_backend_runtimes", description="列出后端执行环境类型。",
+      input_schema={"type": "object", "properties": {}})
+async def list_backend_runtimes_fn(args):
+    runtimes = [
+        {"backend_runtime": "local", "name": "本地 Python 运行时",
+         "tool_count": len(_router.registry.find_by_backend_runtime("local"))},
+        {"backend_runtime": "docker", "name": "Docker 容器运行时",
+         "tool_count": len(_router.registry.find_by_backend_runtime("docker"))},
+        {"backend_runtime": "remote", "name": "远程 API / 云端服务",
+         "tool_count": len(_router.registry.find_by_backend_runtime("remote"))},
+    ]
+    return _result({"backend_runtimes": runtimes})
+
+
+@tool(name="check_task_status", description="查询异步任务状态。",
+      input_schema={"type": "object", "properties": {
+          "task_id": {"type": "string"}}, "required": ["task_id"]})
+async def check_task_status_fn(args):
+    task = _router.get_task_status(args["task_id"])
+    if not task:
+        return _result({"error": "Task not found"})
+    return _result(task)
+
+
+# ===========================================================================
+#  提交任务书给 RouterAgent
+# ===========================================================================
+
+@tool(name="submit_task", description="""
+撰写任务书并提交给 RouterAgent,由它调度 CodingAgent 创建新工具。
+
+你需要先理解用户需求,然后撰写详细任务书(task_spec),包含:
+1. 功能描述  2. 输入输出 JSON Schema  3. 实现要求  4. tool_id
+""", input_schema={"type": "object", "properties": {
+    "tool_id": {"type": "string", "description": "工具 ID"},
+    "description": {"type": "string", "description": "简短功能描述"},
+    "task_spec": {"type": "string", "description": "详细任务书"},
+}, "required": ["tool_id", "description", "task_spec"]})
+async def submit_task_fn(args):
+    task_id = f"create_{uuid.uuid4().hex[:8]}"
+    # 提交给 Router(RouterAgent),由它调度 CodingAgent
+    _router.submit_create_task(task_id, args["task_spec"])
+    return _result({"task_id": task_id, "status": "submitted_to_router",
+                     "message": f"任务书已提交给 RouterAgent,tool_id={args['tool_id']}"})
+
+
+ALL_TOOLS = [
+    list_tools_fn, get_tool_details_fn, list_groups_fn,
+    list_backend_runtimes_fn, check_task_status_fn, submit_task_fn,
+]
+
+
+# ===========================================================================
+#  动态上下文
+# ===========================================================================
+
+def _build_context(router: Router) -> str:
+    """构造实时系统上下文,注入到每次对话中"""
+    tools = []
+    for t in router.registry.list_all():
+        route = router.status_manager.get_status(t.tool_id)
+        tools.append({
+            "tool_id": t.tool_id, "name": t.name,
+            "description": t.description[:80],
+            "backend_runtime": t.backend_runtime.value if t.backend_runtime else "unknown",
+            "group_ids": t.group_ids,
+            "state": route.state.value if route else "stopped",
+        })
+    groups = [{"group_id": g.group_id, "name": g.name,
+               "tool_ids": g.tool_ids, "usage_order": g.usage_order}
+              for g in router.registry.group_manager.list_all()]
+    coding = router.get_coding_agent_status()
+    return json.dumps({"tools": tools, "groups": groups,
+                        "coding_agent": coding}, ensure_ascii=False, indent=2)
+
+
+# ===========================================================================
+#  System Prompt
+# ===========================================================================
+
+SYSTEM_PROMPT = """你是 Tool Agent 系统的智能客服。
+
+**身份**:你是客服助手,负责与外部 Agent 交流。每个对话窗口对应一个独立的你。
+
+**职责**:
+1. 帮助外部 Agent 了解可用工具、工具组、使用方法
+2. 解答工具参数、输入输出格式、调用顺序等问题
+3. 当用户需要新工具时,撰写详细任务书,通过 submit_task 提交给 RouterAgent
+
+**你拥有的工具**:
+- `list_tools` — 列出所有工具
+- `get_tool_details` — 查看工具详情(参数、Schema)
+- `list_groups` — 列出工具组及使用顺序
+- `list_backend_runtimes` — 列出后端执行环境类型
+- `check_task_status` — 查询任务进度
+- `submit_task` — 撰写任务书提交给 RouterAgent(由它调度 CodingAgent 创建工具)
+
+**架构说明**:
+- 所有工具对外统一为本地 Python/FastAPI 调用层
+- 后端执行环境分 local(本地 Python)、docker、remote(云端 API)
+- 你不能直接调用工具或启停工具,只能查询和提交任务书
+- RouterAgent 负责接收你的任务书并调度 CodingAgent 实现
+
+**规则**:
+1. 基于实时上下文中的工具信息回答
+2. 推荐工具时优先介绍工具组和使用顺序
+3. 不确定时主动询问
+4. 友好、简洁、专业
+"""
+
+
+# ===========================================================================
+#  ServiceAgent 类
+# ===========================================================================
+
+class ServiceAgent:
+    """只读客服 Agent,按 session_id 维护对话记忆"""
+
+    def __init__(self, router: Router, model: str = "claude-sonnet-4-5"):
+        self.router = router
+        self.model = model
+        global _router
+        _router = router
+
+    async def chat(self, message: str, session_id: str | None = None) -> tuple[str, str]:
+        """对话。返回 (回复文本, session_id)"""
+        mcp_server = create_sdk_mcp_server(
+            name=MCP_SERVER_NAME, version="1.0.0", tools=ALL_TOOLS)
+
+        ctx = _build_context(self.router)
+        enhanced = f"[实时系统上下文]\n{ctx}\n\n用户消息:{message}"
+
+        options = ClaudeAgentOptions(
+            system_prompt=SYSTEM_PROMPT,
+            mcp_servers={MCP_SERVER_NAME: mcp_server},
+            model=self.model,
+            setting_sources=["project"],
+            allowed_tools=[f"mcp__{MCP_SERVER_NAME}__{t.name}" for t in ALL_TOOLS],
+        )
+        if session_id:
+            options.resume = session_id
+
+        result_text = ""
+        actual_sid = session_id
+
+        async with ClaudeSDKClient(options=options) as client:
+            await client.query(enhanced)
+            for msg in client.get_conversation():
+                if isinstance(msg, AssistantMessage):
+                    for block in msg.content:
+                        if isinstance(block, TextBlock):
+                            result_text += block.text
+            actual_sid = getattr(client, 'session_id', None) or actual_sid
+
+        return result_text, actual_sid or str(uuid.uuid4())

+ 151 - 0
src/tool_agent/service/session.py

@@ -0,0 +1,151 @@
+"""SessionManager — 会话管理 + IM Client 多窗口接入
+
+架构:
+- 一个 IMClient 实例(contact_id="tool_agent")
+- 一个 ServiceAgent 实例(共享,只读 + 提交任务书)
+- 多个 chat_id 窗口,每个窗口维护独立的 Claude SDK session
+- 外部 Agent 通过 contact_id + chat_id 咨询
+- 收到 IM 消息时,按 chat_id 路由到对应 session
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from tool_agent.router.agent import Router
+
+logger = logging.getLogger(__name__)
+
+# im-client 路径
+_IM_CLIENT_DIR = str(
+    Path(__file__).resolve().parent.parent.parent / "im-client"
+)
+if _IM_CLIENT_DIR not in sys.path:
+    sys.path.insert(0, _IM_CLIENT_DIR)
+
+
+class SessionManager:
+    """一个 ServiceAgent 实例 + 多个 chat_id 窗口"""
+
+    def __init__(self, router: Router) -> None:
+        self.router = router
+        self._sdk_sessions: dict[str, str] = {}  # chat_id → Claude SDK session_id
+        self._agent = None  # lazy init, 单实例
+        self._im_client = None
+        self._im_task = None
+
+    def _get_agent(self):
+        if self._agent is None:
+            from tool_agent.service.agent import ServiceAgent
+            self._agent = ServiceAgent(self.router)
+        return self._agent
+
+    # ---- 对话入口(HTTP 和 IM 共用)----
+
+    async def chat(self, message: str, chat_id: str) -> tuple[str, str]:
+        """处理一条消息。同一个 ServiceAgent,按 chat_id 隔离 session。"""
+        agent = self._get_agent()
+        sdk_sid = self._sdk_sessions.get(chat_id)
+        response, new_sid = await agent.chat(message, session_id=sdk_sid)
+        self._sdk_sessions[chat_id] = new_sid
+        return response, chat_id
+
+    def list_sessions(self) -> list[str]:
+        return list(self._sdk_sessions.keys())
+
+    def get_status(self) -> dict:
+        """返回所有窗口和会话的状态"""
+        windows = []
+        for chat_id, sdk_sid in self._sdk_sessions.items():
+            windows.append({
+                "chat_id": chat_id,
+                "sdk_session_id": sdk_sid,
+                "has_im_window": (
+                    self._im_client is not None
+                    and chat_id in self._im_client._windows
+                ),
+            })
+        return {
+            "total_windows": len(self._sdk_sessions),
+            "im_connected": self._im_client is not None and self._im_task is not None,
+            "windows": windows,
+        }
+
+    # ---- IM Client 接入 ----
+
+    async def start_im(
+        self,
+        contact_id: str = "tool_agent",
+        server_url: str = "ws://localhost:8000",
+    ):
+        """启动 IM Client,连接 IM Server。"""
+        from client import IMClient
+        from notifier import AgentNotifier
+
+        self._im_client = IMClient(
+            contact_id=contact_id,
+            server_url=server_url,
+        )
+        self._im_task = asyncio.create_task(self._im_client.run())
+        logger.info(f"IM Client 已启动,身份: {contact_id}")
+
+    def open_chat_window(self, chat_id: str):
+        """为某个 chat_id 打开 IM 窗口,注册消息回调。"""
+        if self._im_client is None:
+            logger.warning("IM Client 未启动,无法打开窗口")
+            return
+
+        from notifier import AgentNotifier
+
+        manager = self
+
+        class _WindowNotifier(AgentNotifier):
+            """收到该窗口的消息时,自动调用 ServiceAgent 处理并回复。"""
+            async def notify(self, count: int, from_contacts: list[str]):
+                await manager._handle_window_messages(chat_id, from_contacts)
+
+        self._im_client.open_window(chat_id, notifier=_WindowNotifier())
+        logger.info(f"已打开 IM 窗口: chat_id={chat_id}")
+
+    async def _handle_window_messages(self, chat_id: str, from_contacts: list[str]):
+        """处理某个窗口的新消息。"""
+        if self._im_client is None:
+            return
+
+        pending = self._im_client.read_pending(chat_id)
+        for msg in pending:
+            sender = msg.get("sender", "unknown")
+            content = msg.get("content", "")
+            if not content.strip():
+                continue
+
+            logger.info(f"IM [{chat_id}] 收到: {sender} -> {content[:60]}")
+
+            try:
+                response, _ = await self.chat(content, chat_id=chat_id)
+                # 回复到对方的 sender_chat_id(如果有的话)
+                sender_chat_id = msg.get("sender_chat_id")
+                self._im_client.send_message(
+                    chat_id=chat_id,
+                    receiver=sender,
+                    content=response,
+                    receiver_chat_id=sender_chat_id,
+                )
+                logger.info(f"IM [{chat_id}] 回复: -> {sender} {response[:60]}")
+            except Exception as e:
+                logger.error(f"IM [{chat_id}] 处理失败: {e}")
+
+    async def stop_im(self):
+        if self._im_task:
+            self._im_task.cancel()
+            try:
+                await self._im_task
+            except asyncio.CancelledError:
+                pass
+            self._im_task = None
+        logger.info("IM Client 已停止")

BIN
src/tool_agent/tool/__pycache__/__init__.cpython-312.pyc


BIN
src/tool_agent/tool/__pycache__/agent.cpython-312.pyc


+ 125 - 10
src/tool_agent/tool/agent.py

@@ -23,7 +23,7 @@ from claude_agent_sdk import (
 
 from tool_agent.config import settings
 from tool_agent.models import (
-    AgentMessage, MessageType, ToolMeta, ToolStatus,
+    AgentMessage, MessageType, ToolMeta, ToolStatus, BackendRuntime,
 )
 from tool_agent.runtime.docker_runner import DockerRunner
 from tool_agent.runtime.local_runner import LocalRunner
@@ -266,11 +266,13 @@ async def uv_add_dependency(args):
 Args:
     path: 文件绝对路径
     content: 文件内容
+    mode: 写入模式,"overwrite"(覆盖,默认) 或 "append"(追加)
 """, input_schema={
     "type": "object",
     "properties": {
         "path": {"type": "string", "description": "文件绝对路径"},
         "content": {"type": "string", "description": "文件内容"},
+        "mode": {"type": "string", "enum": ["overwrite", "append"], "description": "写入模式,默认 overwrite"},
     },
     "required": ["path", "content"],
 })
@@ -280,8 +282,15 @@ async def write_file(args):
         if p is None:
             return _text_result({"status": "error", "error": f"Path not allowed: {args['path']}. Must be under data/ or tools/."})
         p.parent.mkdir(parents=True, exist_ok=True)
-        p.write_text(args["content"], encoding="utf-8")
-        result = {"status": "success", "path": str(p), "size": len(args["content"])}
+
+        mode = args.get("mode", "overwrite")
+        if mode == "append":
+            with open(p, "a", encoding="utf-8") as f:
+                f.write(args["content"])
+        else:
+            p.write_text(args["content"], encoding="utf-8")
+
+        result = {"status": "success", "path": str(p), "size": len(args["content"]), "mode": mode}
     except Exception as e:
         result = {"status": "error", "error": str(e)}
     return _text_result(result)
@@ -289,13 +298,18 @@ async def write_file(args):
 
 @tool(name="read_file", description="""
 读取宿主机文件内容。用于阅读 README、配置文件、代码等。
+支持分段读取大文件,避免一次性加载过多内容。
 
 Args:
     path: 文件绝对路径
+    start_line: 起始行号(从 1 开始),默认 1
+    max_lines: 最多读取行数,默认 500(0 表示读取全部)
 """, input_schema={
     "type": "object",
     "properties": {
         "path": {"type": "string", "description": "文件绝对路径"},
+        "start_line": {"type": "integer", "description": "起始行号,默认 1"},
+        "max_lines": {"type": "integer", "description": "最多读取行数,默认 500"},
     },
     "required": ["path"],
 })
@@ -307,13 +321,96 @@ async def read_file(args):
         if not p.exists():
             result = {"status": "error", "error": f"File not found: {p}"}
         else:
-            content = p.read_text(encoding="utf-8", errors="replace")
-            result = {"status": "success", "path": str(p), "content": content}
+            start_line = args.get("start_line", 1)
+            max_lines = args.get("max_lines", 500)
+
+            with open(p, "r", encoding="utf-8", errors="replace") as f:
+                lines = f.readlines()
+
+            total_lines = len(lines)
+            start_idx = max(0, start_line - 1)
+
+            if max_lines == 0:
+                selected_lines = lines[start_idx:]
+            else:
+                end_idx = start_idx + max_lines
+                selected_lines = lines[start_idx:end_idx]
+
+            content = "".join(selected_lines)
+            result = {
+                "status": "success",
+                "path": str(p),
+                "content": content,
+                "total_lines": total_lines,
+                "start_line": start_line,
+                "lines_read": len(selected_lines),
+            }
     except Exception as e:
         result = {"status": "error", "error": str(e)}
     return _text_result(result)
 
 
+@tool(name="fetch_url", description="""
+获取网页内容。用于读取在线文档、API 文档、GitHub README 等。
+自动将 HTML 转换为纯文本,去除标签。
+
+Args:
+    url: 网页 URL
+    max_length: 最大返回字符数,默认 50000(避免过大响应)
+    keep_html: 是否保留 HTML 标签,默认 False
+""", input_schema={
+    "type": "object",
+    "properties": {
+        "url": {"type": "string", "description": "网页 URL"},
+        "max_length": {"type": "integer", "description": "最大返回字符数,默认 50000"},
+        "keep_html": {"type": "boolean", "description": "是否保留 HTML 标签,默认 False"},
+    },
+    "required": ["url"],
+})
+async def fetch_url(args):
+    try:
+        import httpx
+        import re
+        url = args["url"]
+        max_length = args.get("max_length", 50000)
+        keep_html = args.get("keep_html", False)
+
+        async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+            resp = await client.get(url)
+            resp.raise_for_status()
+
+            content = resp.text
+
+            # 如果不保留 HTML,转换为纯文本
+            if not keep_html and "text/html" in resp.headers.get("content-type", ""):
+                # 移除 script 和 style 标签及其内容
+                content = re.sub(r'<script[^>]*>.*?</script>', '', content, flags=re.DOTALL | re.IGNORECASE)
+                content = re.sub(r'<style[^>]*>.*?</style>', '', content, flags=re.DOTALL | re.IGNORECASE)
+                # 移除所有 HTML 标签
+                content = re.sub(r'<[^>]+>', '', content)
+                # 解码 HTML 实体
+                import html
+                content = html.unescape(content)
+                # 清理多余空白
+                content = re.sub(r'\n\s*\n', '\n\n', content)
+                content = content.strip()
+
+            content = content[:max_length]
+            truncated = len(resp.text) > max_length
+
+            result = {
+                "status": "success",
+                "url": url,
+                "content": content,
+                "status_code": resp.status_code,
+                "content_length": len(resp.text),
+                "truncated": truncated,
+            }
+    except Exception as e:
+        result = {"status": "error", "error": str(e), "url": args["url"]}
+    return _text_result(result)
+
+
 # ===========================================================================
 #  工具注册
 # ===========================================================================
@@ -329,7 +426,7 @@ Args:
     input_schema: JSON Schema 格式的输入参数定义
     output_schema: JSON Schema 格式的输出定义
     stream_support: 是否支持流式返回,默认 False
-    runtime_type: "local" 或 "docker"
+    runtime_type: "local", "docker" 或 "remote"(后端执行环境)
     container_id: Docker 容器 ID(docker 类型必填)
     host_dir: 宿主机工作目录(代码所在目录,local 类型必填)
     internal_port: 容器/进程内服务端口
@@ -348,12 +445,13 @@ Returns:
         "input_schema": {"type": "object", "description": "输入参数 JSON Schema"},
         "output_schema": {"type": "object", "description": "输出 JSON Schema"},
         "stream_support": {"type": "boolean", "description": "是否支持流式"},
-        "runtime_type": {"type": "string", "enum": ["local", "docker"], "description": "运行时类型"},
+        "runtime_type": {"type": "string", "enum": ["local", "docker", "remote"], "description": "后端执行环境"},
         "container_id": {"type": "string", "description": "Docker 容器 ID(docker 类型必填)"},
         "host_dir": {"type": "string", "description": "宿主机工作目录(local 类型必填)"},
         "internal_port": {"type": "integer", "description": "容器/进程内服务端口"},
         "endpoint_path": {"type": "string", "description": "API 路径,默认 /"},
         "http_method": {"type": "string", "description": "HTTP 方法,默认 POST"},
+        "tool_slug_ids": {"type": "array", "items": {"type": "string"}, "description": "关联的 KnowHub 工具 tool_slug 列表,如 [\"comfyui\"],无则留空"},
     },
     "required": ["tool_id", "name", "description", "runtime_type", "internal_port"],
 })
@@ -389,6 +487,11 @@ async def register_tool_fn(args):
                 host_dir = host_dir_input
 
         # 1. 注册元数据到 Registry
+        backend_runtime = {
+            "docker": BackendRuntime.DOCKER,
+            "local": BackendRuntime.LOCAL,
+        }.get(runtime_type, BackendRuntime.REMOTE)
+
         tool_meta = ToolMeta(
             tool_id=tool_id,
             name=args["name"],
@@ -398,6 +501,9 @@ async def register_tool_fn(args):
             output_schema=args.get("output_schema", {}),
             stream_support=args.get("stream_support", False),
             status=ToolStatus.ACTIVE,
+            backend_runtime=backend_runtime,
+            group_ids=args.get("group_ids", []),
+            tool_slug_ids=args.get("tool_slug_ids", []),
         )
         _registry.register(tool_meta)
 
@@ -426,7 +532,7 @@ async def register_tool_fn(args):
         result = {
             "status": "success",
             "tool_id": tool_id,
-            "runtime": runtime_type,
+            "backend_runtime": runtime_type,
             "message": f"Tool '{tool_meta.name}' registered successfully. Metadata saved to registry, source info saved to router.",
         }
     except Exception as e:
@@ -476,7 +582,12 @@ SYSTEM_PROMPT = """你是 Coding Agent,负责根据任务书(task_spec)编
 
 ### 文件操作(宿主机)
 - write_file: 写文件(代码、配置文件)— 配合 Docker volume 挂载实时同步
+  - 支持 mode="append" 追加内容到文件末尾
+  - **重要**:单次写入内容不要超过 500 行,大文件请分段写入(先 overwrite 写头部,再多次 append 追加)
 - read_file: 读文件(README、文档、代码)
+  - 支持 start_line 和 max_lines 参数分段读取大文件
+- fetch_url: 获取网页内容(自动转纯文本,去除 HTML 标签)
+  - 用于读取在线文档、GitHub README、API 文档等
 
 ### 注册
 - register_tool: 将工具注册到 registry,使其可被外部调用
@@ -534,6 +645,10 @@ SYSTEM_PROMPT = """你是 Coding Agent,负责根据任务书(task_spec)编
 - 遇到错误要尝试修复,不要轻易放弃
 - 失败时给出清晰的错误原因和已尝试的方案
 - 测试阶段只验证核心逻辑(单元测试),不要启动 HTTP 服务器。写好 FastAPI 代码即可,服务启动由 Router 层在调用时负责
+- **文件写入规则**:单次 write_file 内容不超过 300 行。大文件必须分段写入:
+  1. 第一次用 mode="overwrite" 写入文件头部(import、类定义等)
+  2. 后续用 mode="append" 多次追加剩余内容
+  3. 每次追加前可用 read_file 确认当前内容
 """
 
 
@@ -544,7 +659,7 @@ SYSTEM_PROMPT = """你是 Coding Agent,负责根据任务书(task_spec)编
 ALL_TOOLS = [
     create_docker_env, run_in_docker, rebuild_docker_ports, destroy_docker_env,
     create_uv_project, run_in_uv, uv_add_dependency,
-    write_file, read_file,
+    write_file, read_file, fetch_url,
     register_tool_fn,
 ]
 
@@ -688,7 +803,7 @@ class CodingAgent:
                         result_text = message.result
 
         # 任务结束,清理后台进程
-        _local_runner.cleanup_background()
+        await _local_runner.cleanup_background()
 
         return result_text
 

+ 304 - 0
src/tool_agent/tool_table.py

@@ -0,0 +1,304 @@
+"""KnowHub 工具表集成模块
+
+提供工具表的查询、创建、更新功能,用于与 KnowHub 知识库同步工具信息。
+"""
+
+import httpx
+import logging
+from typing import Any, Optional
+from pydantic import BaseModel, Field
+
+from tool_agent.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class ToolTableClient:
+    """KnowHub 工具表客户端"""
+
+    def __init__(self, api_url: str | None = None):
+        self.api_url = api_url or settings.knowhub_api
+        self.timeout = 60.0
+
+    def list_all_tools(self, limit: int = 1000) -> list[dict[str, Any]]:
+        """列出所有工具
+
+        Returns:
+            工具列表
+        """
+        try:
+            resp = httpx.get(
+                f"{self.api_url}/api/resource",
+                params={"limit": limit},
+                timeout=self.timeout
+            )
+            resp.raise_for_status()
+            data = resp.json()
+
+            # 提取 results 字段
+            if isinstance(data, dict) and "results" in data:
+                data = data["results"]
+
+            # 过滤出工具(ID 以 tools/ 开头)
+            tools = [r for r in data if isinstance(r, dict) and r.get("id", "").startswith("tools/")]
+            logger.info(f"Found {len(tools)} tools in KnowHub")
+            return tools
+
+        except Exception as e:
+            logger.error(f"Failed to list tools: {e}")
+            return []
+
+    def get_tool(self, tool_id: str) -> dict[str, Any] | None:
+        """获取工具详情
+
+        Args:
+            tool_id: 工具 ID,格式:tools/{category}/{slug}
+
+        Returns:
+            工具详情,失败返回 None
+        """
+        try:
+            resp = httpx.get(
+                f"{self.api_url}/api/resource/{tool_id}",
+                timeout=self.timeout
+            )
+            resp.raise_for_status()
+            tool = resp.json()
+            logger.info(f"Retrieved tool: {tool_id}")
+            return tool
+
+        except Exception as e:
+            logger.error(f"Failed to get tool {tool_id}: {e}")
+            return None
+
+    def search_tools_by_category(self, category: str) -> list[dict[str, Any]]:
+        """按分类搜索工具
+
+        Args:
+            category: 工具分类(image_gen, image_process, model, plugin, workflow, other)
+
+        Returns:
+            工具列表
+        """
+        all_tools = self.list_all_tools()
+        tools = [
+            t for t in all_tools
+            if t.get("metadata", {}).get("category") == category
+        ]
+        logger.info(f"Found {len(tools)} tools in category '{category}'")
+        return tools
+
+    def create_tool(
+        self,
+        tool_id: str,
+        title: str,
+        category: str,
+        tool_slug: str,
+        status: str = "未接入",
+        description: str | None = None,
+        usage: str | None = None,
+        scenarios: list[str] | None = None,
+        **kwargs
+    ) -> dict[str, Any] | None:
+        """创建新工具
+
+        Args:
+            tool_id: 工具 ID,格式:tools/{category}/{slug}
+            title: 工具标题
+            category: 工具分类
+            tool_slug: 工具短名
+            status: 接入状态(未接入/已接入/测试中)
+            description: 功能描述
+            usage: 使用说明
+            scenarios: 应用场景列表
+            **kwargs: 其他 metadata 字段
+
+        Returns:
+            创建结果,失败返回 None
+        """
+        try:
+            tool_data = {
+                "id": tool_id,
+                "title": title,
+                "body": "",
+                "content_type": "text",
+                "metadata": {
+                    "category": category,
+                    "tool_slug": tool_slug,
+                    "status": status,
+                    "description": description,
+                    "usage": usage,
+                    "scenarios": scenarios or [],
+                    "knowledge_ids": [],
+                    **kwargs
+                },
+                "submitted_by": "tool_agent"
+            }
+
+            resp = httpx.post(
+                f"{self.api_url}/api/resource",
+                json=tool_data,
+                timeout=self.timeout
+            )
+            resp.raise_for_status()
+            result = resp.json()
+            logger.info(f"Created tool: {tool_id}")
+            return result
+
+        except Exception as e:
+            logger.error(f"Failed to create tool {tool_id}: {e}")
+            return None
+
+    def update_tool_status(self, tool_id: str, new_status: str) -> bool:
+        """更新工具状态
+
+        Args:
+            tool_id: 工具 ID
+            new_status: 新状态(未接入/已接入/测试中)
+
+        Returns:
+            是否成功
+        """
+        try:
+            # 1. 获取当前工具信息
+            tool = self.get_tool(tool_id)
+            if not tool:
+                return False
+
+            # 2. 更新 metadata
+            metadata = tool.get("metadata", {})
+            metadata["status"] = new_status
+
+            resp = httpx.patch(
+                f"{self.api_url}/api/resource/{tool_id}",
+                json={"metadata": metadata},
+                timeout=self.timeout
+            )
+            resp.raise_for_status()
+            logger.info(f"Updated tool {tool_id} status to '{new_status}'")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to update tool {tool_id} status: {e}")
+            return False
+
+    def add_knowledge_link(self, tool_id: str, knowledge_ids: list[str]) -> bool:
+        """添加知识关联
+
+        Args:
+            tool_id: 工具 ID
+            knowledge_ids: 知识 ID 列表
+
+        Returns:
+            是否成功
+        """
+        try:
+            # 1. 获取当前工具信息
+            tool = self.get_tool(tool_id)
+            if not tool:
+                return False
+
+            # 2. 合并知识 ID(去重)
+            metadata = tool.get("metadata", {})
+            current_ids = metadata.get("knowledge_ids", [])
+            updated_ids = list(set(current_ids + knowledge_ids))
+            metadata["knowledge_ids"] = updated_ids
+
+            resp = httpx.patch(
+                f"{self.api_url}/api/resource/{tool_id}",
+                json={"metadata": metadata},
+                timeout=self.timeout
+            )
+            resp.raise_for_status()
+            logger.info(f"Added {len(knowledge_ids)} knowledge links to tool {tool_id}")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to add knowledge links to tool {tool_id}: {e}")
+            return False
+
+    def update_toolhub_items(self, tool_id: str, toolhub_items: list[dict[str, str]]) -> bool:
+        """更新工具关联的 Tool Agent 工具列表
+
+        Args:
+            tool_id: KnowHub 工具 ID,格式:tools/{category}/{slug}
+            toolhub_items: Tool Agent 工具列表,每项为 {tool_id: name}
+
+        Returns:
+            是否成功
+        """
+        try:
+            tool = self.get_tool(tool_id)
+            if not tool:
+                return False
+
+            metadata = tool.get("metadata", {})
+            metadata["toolhub_items"] = toolhub_items
+
+            resp = httpx.patch(
+                f"{self.api_url}/api/resource/{tool_id}",
+                json={"metadata": metadata},
+                timeout=self.timeout
+            )
+            resp.raise_for_status()
+            logger.info(f"Updated toolhub_items for {tool_id}: {len(toolhub_items)} items")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to update toolhub_items for {tool_id}: {e}")
+            return False
+
+    def sync_tool_from_registry(
+        self,
+        tool_id: str,
+        name: str,
+        category: str,
+        description: str,
+        backend_runtime: str
+    ) -> bool:
+        """从 Tool Agent Registry 同步工具到 KnowHub
+
+        Args:
+            tool_id: 工具 ID(Tool Agent 中的 tool_id)
+            name: 工具名称
+            category: 工具分类
+            description: 工具描述
+            backend_runtime: 后端运行时(local/docker/remote)
+
+        Returns:
+            是否成功
+        """
+        knowhub_tool_id = f"tools/{category}/{tool_id}"
+
+        # 检查工具是否已存在
+        existing_tool = self.get_tool(knowhub_tool_id)
+
+        if existing_tool:
+            # 工具已存在,更新状态为"已接入"
+            logger.info(f"Tool {knowhub_tool_id} already exists, updating status")
+            return self.update_tool_status(knowhub_tool_id, "已接入")
+        else:
+            # 创建新工具
+            logger.info(f"Creating new tool {knowhub_tool_id}")
+            result = self.create_tool(
+                tool_id=knowhub_tool_id,
+                title=name,
+                category=category,
+                tool_slug=tool_id,
+                status="已接入",
+                description=description,
+                backend_runtime=backend_runtime
+            )
+            return result is not None
+
+
+# 全局客户端实例
+_client: ToolTableClient | None = None
+
+
+def get_client() -> ToolTableClient:
+    """获取全局 ToolTableClient 实例"""
+    global _client
+    if _client is None:
+        _client = ToolTableClient()
+    return _client

+ 46 - 0
test_router_agent.py

@@ -0,0 +1,46 @@
+"""测试 Router Agent 基本功能"""
+
+import asyncio
+import logging
+import sys
+
+# 修复 Windows 控制台编码问题
+if sys.platform == 'win32':
+    import io
+    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
+
+from tool_agent.router.agent import Router
+from tool_agent.router.router_agent import RouterAgent
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
+)
+
+async def test_router_agent():
+    """测试 Router Agent 的基本功能"""
+    print("=" * 60)
+    print("Testing Router Agent")
+    print("=" * 60)
+
+    # 初始化 Router
+    router = Router()
+    router_agent = RouterAgent(router)
+
+    # 测试 1: 搜索工具
+    print("\n[Test 1] Searching for tools...")
+    response = await router_agent.chat("列出所有可用的工具")
+    print(f"Response: {response}")
+
+    # 测试 2: 创建工具请求
+    print("\n[Test 2] Requesting tool creation...")
+    response = await router_agent.chat("我需要一个图片压缩工具")
+    print(f"Response: {response}")
+
+    print("\n" + "=" * 60)
+    print("Tests completed")
+    print("=" * 60)
+
+if __name__ == "__main__":
+    asyncio.run(test_router_agent())

+ 74 - 0
test_router_chat_api.py

@@ -0,0 +1,74 @@
+"""测试 Router Agent 的 HTTP 对话接口"""
+
+import asyncio
+import httpx
+import sys
+
+# 修复 Windows 控制台编码问题
+if sys.platform == 'win32':
+    import io
+    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
+
+
+async def test_chat_api():
+    """测试 Router Agent 的对话接口"""
+    base_url = "http://127.0.0.1:8001"
+
+    print("=" * 60)
+    print("Testing Router Agent Chat API")
+    print("=" * 60)
+
+    async with httpx.AsyncClient(timeout=60.0) as client:
+        # 测试 1: 健康检查
+        print("\n[Test 1] Health check...")
+        try:
+            response = await client.get(f"{base_url}/health")
+            print(f"✓ Health: {response.json()}")
+        except Exception as e:
+            print(f"✗ Health check failed: {e}")
+            return
+
+        # 测试 2: 对话 - 列出工具
+        print("\n[Test 2] Chat - List tools...")
+        try:
+            response = await client.post(
+                f"{base_url}/chat",
+                json={"message": "列出所有可用的工具"}
+            )
+            result = response.json()
+            print(f"✓ Response:\n{result.get('response', result)}")
+        except Exception as e:
+            print(f"✗ Chat failed: {e}")
+
+        # 测试 3: 对话 - 询问工具使用
+        print("\n[Test 3] Chat - Ask about tool usage...")
+        try:
+            response = await client.post(
+                f"{base_url}/chat",
+                json={"message": "image_stitcher 工具怎么用?"}
+            )
+            result = response.json()
+            print(f"✓ Response:\n{result['response']}")
+        except Exception as e:
+            print(f"✗ Chat failed: {e}")
+
+        # 测试 4: 对话 - 请求创建工具
+        print("\n[Test 4] Chat - Request tool creation...")
+        try:
+            response = await client.post(
+                f"{base_url}/chat",
+                json={"message": "我需要一个 PDF 转图片的工具"}
+            )
+            result = response.json()
+            print(f"✓ Response:\n{result['response']}")
+        except Exception as e:
+            print(f"✗ Chat failed: {e}")
+
+    print("\n" + "=" * 60)
+    print("Tests completed")
+    print("=" * 60)
+
+
+if __name__ == "__main__":
+    asyncio.run(test_chat_api())

Разница между файлами не показана из-за своего большого размера
+ 1 - 4
tests/tasks/runcomfy_launch_env.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
tests/tasks/runcomfy_run_only.json


+ 1 - 1
tests/tasks/runcomfy_stop_env.json

@@ -1,6 +1,6 @@
 {
   "description": "RunComfy 环境销毁工具:根据 server_id 主动关闭已启动的 ComfyUI 机器,避免持续计费",
-  "task_spec": "脚本位置:tests/run_comfy/stop_comfy_env.py\n\n## 功能\n调用 RunComfy Server API 删除指定的机器实例,释放资源。配合 `launch_comfy_env` 使用,负责生命周期的收尾阶段。\n\n## 环境变量\n- RUNCOMFY_USER_ID:RunComfy 用户 ID\n- API_TOKEN:RunComfy API Token\n\n## 命令行参数\n- --server-id:(必填) 要关闭的机器 ID\n\n## HTTP 接口需求(用于注册到 Router)\n如果作为服务接口,实现 `POST /stop`:\n### 输入 JSON\n- server_id: str (必填,需关闭的机器)\n\n### 输出 JSON\n- server_id: str (关闭的机器 ID)\n- status: str (\"Deleted\" 或报错信息)\n- message: str (详细结果文本)\n\n## 核心逻辑参考\n1. 调用 API `DELETE https://beta-api.runcomfy.net/prod/api/users/{USER_ID}/servers/{server_id}`\n2. Header 需要 `Authorization: Bearer {API_TOKEN}`\n3. 处理 200 响应和 404 响应(说明已被清理或不存在),如果是其它错误,则抛出异常并返回失败状态\n4. 这个工具应始终返回安全的明确状态,供 Agent 确认释放情况",
+  "task_spec": "## 目标\n创建一个 HTTP API 工具并注册到 Router,用于关闭 RunComfy 机器实例。\n\n## 核心功能\n调用 RunComfy Server API 删除指定的机器实例,释放资源。配合 `launch_comfy_env` 使用,负责生命周期的收尾阶段。\n\n## 环境变量\n- RUNCOMFY_USER_ID:RunComfy 用户 ID\n- API_TOKEN:RunComfy API Token\n\n## HTTP API 接口(必须实现)\n实现 `POST /stop` 接口:\n\n### 输入 JSON Schema\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"server_id\": {\"type\": \"string\", \"description\": \"要关闭的机器 ID\"}\n  },\n  \"required\": [\"server_id\"]\n}\n```\n\n### 输出 JSON Schema\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"server_id\": {\"type\": \"string\", \"description\": \"关闭的机器 ID\"},\n    \"status\": {\"type\": \"string\", \"description\": \"Deleted 或报错信息\"},\n    \"message\": {\"type\": \"string\", \"description\": \"详细结果文本\"}\n  }\n}\n```\n\n## 核心逻辑\n1. 调用 API `DELETE https://beta-api.runcomfy.net/prod/api/users/{USER_ID}/servers/{server_id}`\n2. Header 需要 `Authorization: Bearer {API_TOKEN}`\n3. 处理 200 响应和 404 响应(说明已被清理或不存在),如果是其它错误,则抛出异常并返回失败状态\n4. 这个工具应始终返回安全的明确状态,供 Agent 确认释放情况\n\n## 实现要求\n1. 使用 uv 创建项目,项目名:runcomfy_stop_env\n2. 使用 FastAPI 实现 HTTP 接口\n3. 从环境变量读取 RUNCOMFY_USER_ID 和 API_TOKEN\n4. 实现 POST /stop 接口\n5. 编写测试脚本验证功能\n6. **必须调用 register_tool 注册到 Router**,tool_id 为 \"runcomfy_stop_env\"",
   "reference_files": [
     "tests/run_comfy/run_workflow.py"
   ]

+ 106 - 0
tests/test_knowhub_api_integration.py

@@ -0,0 +1,106 @@
+"""测试 KnowHub 工具表 API 集成
+
+验证 FastAPI 路由是否正确调用 tool_table_tools 模块
+"""
+
+import sys
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root / "src"))
+
+from tool_agent.router import tool_table_tools
+
+
+def test_query_all_tools():
+    """测试查询所有工具"""
+    print("\n=== 测试:查询所有工具 ===")
+    result = tool_table_tools.query_knowhub_tools()
+
+    print(f"状态: {result['status']}")
+    print(f"工具总数: {result['total']}")
+
+    if result['total'] > 0:
+        print(f"\n前 3 个工具:")
+        for tool in result['tools'][:3]:
+            print(f"  - {tool['title']} ({tool['tool_id']})")
+            print(f"    分类: {tool['category']}, 状态: {tool['status']}")
+
+
+def test_query_by_category():
+    """测试按分类查询"""
+    print("\n=== 测试:按分类查询 (plugin) ===")
+    result = tool_table_tools.query_knowhub_tools(category="plugin")
+
+    print(f"状态: {result['status']}")
+    print(f"plugin 类工具数: {result['total']}")
+
+    for tool in result['tools'][:5]:
+        print(f"  - {tool['title']} | 状态: {tool['status']}")
+
+
+def test_query_by_keyword():
+    """测试关键词搜索"""
+    print("\n=== 测试:关键词搜索 (comfyui) ===")
+    result = tool_table_tools.query_knowhub_tools(keyword="comfyui")
+
+    print(f"状态: {result['status']}")
+    print(f"匹配工具数: {result['total']}")
+
+    for tool in result['tools']:
+        print(f"  - {tool['title']} ({tool['tool_id']})")
+        print(f"    状态: {tool['status']}")
+
+
+def test_get_tool_detail():
+    """测试获取工具详情"""
+    print("\n=== 测试:获取工具详情 (tools/image_gen/comfyui) ===")
+    result = tool_table_tools.get_knowhub_tool_detail("tools/image_gen/comfyui")
+
+    print(f"状态: {result['status']}")
+    if result['status'] == 'success':
+        tool = result['tool']
+        print(f"标题: {tool['title']}")
+        print(f"分类: {tool['category']}")
+        print(f"接入状态: {tool['status']}")
+        print(f"描述: {tool['description']}")
+        print(f"使用方法: {tool['usage']}")
+        print(f"应用场景: {tool['scenarios']}")
+        print(f"关联知识数: {len(tool['knowledge_ids'])}")
+
+
+def test_update_comfyui_status():
+    """测试更新 ComfyUI 状态为已接入"""
+    print("\n=== 测试:更新 ComfyUI 状态为已接入 ===")
+    result = tool_table_tools.update_knowhub_tool_status(
+        "tools/image_gen/comfyui", "已接入"
+    )
+
+    print(f"状态: {result['status']}")
+    print(f"消息: {result['message']}")
+
+    # 验证
+    print("\n验证更新结果:")
+    detail = tool_table_tools.get_knowhub_tool_detail("tools/image_gen/comfyui")
+    if detail['status'] == 'success':
+        print(f"当前状态: {detail['tool']['status']}")
+
+
+def main():
+    print("=" * 60)
+    print("KnowHub 工具表 API 集成测试")
+    print("=" * 60)
+
+    test_query_all_tools()
+    test_query_by_category()
+    test_query_by_keyword()
+    test_get_tool_detail()
+    test_update_comfyui_status()
+
+    print("\n" + "=" * 60)
+    print("测试完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 85 - 0
tests/test_knowhub_modify.py

@@ -0,0 +1,85 @@
+"""批量给 KnowHub 工具表添加 toolhub_items 字段
+
+规则:
+- 已接入的工具:填入对应的 Tool Agent 工具 ID 和名称
+- 未接入的工具:只加空的 toolhub_items 字段
+"""
+
+import httpx
+import os
+import json
+
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999")
+TIMEOUT = 60.0
+
+# Tool Agent registry 中已接入的工具 → KnowHub 工具表的映射
+# key: KnowHub tool_id, value: toolhub_items 列表
+TOOLHUB_MAPPING = {
+    "tools/image_gen/comfyui": [
+        {"launch_comfy_env": "启动云端 ComfyUI Docker 环境,返回 server_id 和 comfy_url"},
+        {"runcomfy_workflow_executor": "在已就绪的 RunComfy 机器上提交 ComfyUI 工作流,上传输入文件,监听执行状态,下载结果图片"},
+        {"runcomfy_stop_env": "停止并删除 RunComfy 机器实例,释放资源"},
+    ],
+}
+
+
+def get_all_tools() -> list[dict]:
+    """获取 KnowHub 所有工具"""
+    resp = httpx.get(f"{KNOWHUB_API}/api/resource", params={"limit": 1000}, timeout=TIMEOUT)
+    resp.raise_for_status()
+    data = resp.json()
+    results = data.get("results", [])
+    return [r for r in results if isinstance(r, dict) and r.get("id", "").startswith("tools/")]
+
+
+def patch_tool(tool_id: str, metadata: dict) -> bool:
+    """PATCH 更新工具 metadata"""
+    resp = httpx.patch(
+        f"{KNOWHUB_API}/api/resource/{tool_id}",
+        json={"metadata": metadata},
+        timeout=TIMEOUT
+    )
+    resp.raise_for_status()
+    return True
+
+
+def main():
+    print(f"KnowHub API: {KNOWHUB_API}")
+    print("=" * 60)
+
+    tools = get_all_tools()
+    print(f"共 {len(tools)} 个工具\n")
+
+    updated = 0
+    skipped = 0
+    failed = 0
+
+    for tool in tools:
+        tool_id = tool["id"]
+        metadata = tool.get("metadata", {})
+
+        # 已经有 toolhub_items 且非空的跳过
+        if metadata.get("toolhub_items"):
+            print(f"  SKIP  {tool_id} (已有 toolhub_items)")
+            skipped += 1
+            continue
+
+        # 查映射表,有则填入,无则空列表
+        items = TOOLHUB_MAPPING.get(tool_id, [])
+        metadata["toolhub_items"] = items
+
+        try:
+            patch_tool(tool_id, metadata)
+            tag = f"{len(items)} items" if items else "empty"
+            print(f"  OK    {tool_id} ({tag})")
+            updated += 1
+        except Exception as e:
+            print(f"  FAIL  {tool_id}: {e}")
+            failed += 1
+
+    print(f"\n{'=' * 60}")
+    print(f"完成: 更新 {updated}, 跳过 {skipped}, 失败 {failed}")
+
+
+if __name__ == "__main__":
+    main()

+ 243 - 0
tests/test_knowhub_query.py

@@ -0,0 +1,243 @@
+"""测试 KnowHub 工具表查询功能"""
+
+import httpx
+import os
+from typing import Any
+
+# KnowHub API 地址
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999")
+
+
+def test_list_all_resources():
+    """测试:列出所有资源"""
+    print("\n=== 测试:列出所有资源 ===")
+    try:
+        resp = httpx.get(f"{KNOWHUB_API}/api/resource", params={"limit": 1000}, timeout=60.0)
+        resp.raise_for_status()
+        data = resp.json()
+
+        print(f"状态码: {resp.status_code}")
+        print(f"返回数据类型: {type(data)}")
+
+        # 检查返回格式
+        if isinstance(data, dict):
+            print(f"返回字典的键: {data.keys()}")
+            # API 返回格式: {"results": [...], "count": N}
+            if "results" in data:
+                data = data["results"]
+            elif "data" in data:
+                data = data["data"]
+
+        if not isinstance(data, list):
+            print(f"⚠️ 返回数据不是列表")
+            return []
+
+        print(f"总资源数: {len(data)}")
+
+        # 过滤出工具
+        tools = [r for r in data if isinstance(r, dict) and r.get("id", "").startswith("tools/")]
+        print(f"工具数量: {len(tools)}")
+
+        if tools:
+            print("\n前 3 个工具:")
+            for tool in tools[:3]:
+                print(f"  - {tool['id']}: {tool.get('title', 'N/A')}")
+
+        return tools
+    except Exception as e:
+        print(f"❌ 错误: {e}")
+        import traceback
+        traceback.print_exc()
+        return []
+
+
+def test_get_tool_detail(tool_id: str):
+    """测试:获取工具详情"""
+    print(f"\n=== 测试:获取工具详情 ({tool_id}) ===")
+    try:
+        resp = httpx.get(f"{KNOWHUB_API}/api/resource/{tool_id}", timeout=10.0)
+        resp.raise_for_status()
+        tool = resp.json()
+
+        print(f"状态码: {resp.status_code}")
+        print(f"ID: {tool['id']}")
+        print(f"标题: {tool.get('title', 'N/A')}")
+
+        metadata = tool.get("metadata", {})
+        print(f"\nMetadata:")
+        for key, value in metadata.items():
+            # 长列表截断显示
+            if isinstance(value, list) and len(value) > 3:
+                print(f"  - {key}: [{len(value)} items] {value[:3]}...")
+            else:
+                print(f"  - {key}: {value}")
+
+        return tool
+    except Exception as e:
+        print(f"❌ 错误: {e}")
+        return None
+
+
+def test_search_tools_by_category(category: str):
+    """测试:按分类搜索工具"""
+    print(f"\n=== 测试:按分类搜索工具 (category={category}) ===")
+    try:
+        resp = httpx.get(f"{KNOWHUB_API}/api/resource", params={"limit": 1000}, timeout=60.0)
+        resp.raise_for_status()
+        data = resp.json()
+
+        # 检查返回格式
+        if isinstance(data, dict):
+            # API 返回格式: {"results": [...], "count": N}
+            if "results" in data:
+                data = data["results"]
+            elif "data" in data:
+                data = data["data"]
+
+        if not isinstance(data, list):
+            print(f"⚠️ 返回数据不是列表")
+            return []
+
+        # 客户端过滤
+        tools = [
+            r for r in data
+            if isinstance(r, dict) and r.get("id", "").startswith("tools/")
+            and r.get("metadata", {}).get("category") == category
+        ]
+
+        print(f"找到 {len(tools)} 个 {category} 类工具:")
+        for tool in tools[:5]:
+            print(f"  - {tool['id']}: {tool.get('title', 'N/A')}")
+
+        return tools
+    except Exception as e:
+        print(f"❌ 错误: {e}")
+        import traceback
+        traceback.print_exc()
+        return []
+
+
+def test_search_knowledge_by_tool(query: str, top_k: int = 5):
+    """测试:搜索工具相关知识"""
+    print(f"\n=== 测试:搜索工具相关知识 (query={query}) ===")
+    try:
+        resp = httpx.get(
+            f"{KNOWHUB_API}/api/knowledge/search",
+            params={"q": query, "top_k": top_k, "min_score": 3},
+            timeout=60.0
+        )
+        resp.raise_for_status()
+        results = resp.json()
+
+        print(f"状态码: {resp.status_code}")
+        print(f"返回数据类型: {type(results)}")
+
+        # 检查返回格式
+        if isinstance(results, dict):
+            print(f"返回字典的键: {results.keys()}")
+            if "results" in results:
+                results = results["results"]
+            elif "data" in results:
+                results = results["data"]
+
+        if not isinstance(results, list):
+            print(f"⚠️ 返回数据不是列表")
+            return []
+
+        print(f"找到 {len(results)} 条知识:")
+
+        for i, item in enumerate(results[:3], 1):
+            if isinstance(item, dict):
+                print(f"\n  {i}. {item.get('id', 'N/A')}")
+                title = item.get('title', 'N/A')
+                print(f"     标题: {title[:50] if isinstance(title, str) else title}")
+                print(f"     分数: {item.get('score', 0):.2f}")
+                print(f"     关联工具: {item.get('resource_ids', [])}")
+
+        return results
+    except Exception as e:
+        print(f"❌ 错误: {e}")
+        import traceback
+        traceback.print_exc()
+        return []
+
+
+def test_list_tool_knowledge(tags: str = "tool"):
+    """测试:列出所有工具相关知识"""
+    print(f"\n=== 测试:列出所有工具相关知识 (tags={tags}) ===")
+    try:
+        resp = httpx.get(
+            f"{KNOWHUB_API}/api/knowledge",
+            params={"tags": tags, "status": "approved,checked", "page_size": 200},
+            timeout=60.0
+        )
+        resp.raise_for_status()
+        results = resp.json()
+
+        print(f"状态码: {resp.status_code}")
+        print(f"返回数据类型: {type(results)}")
+
+        # 检查返回格式
+        if isinstance(results, dict):
+            print(f"返回字典的键: {results.keys()}")
+            if "results" in results:
+                results = results["results"]
+            elif "data" in results:
+                results = results["data"]
+            elif "items" in results:
+                results = results["items"]
+
+        if not isinstance(results, list):
+            print(f"⚠️ 返回数据不是列表")
+            return []
+
+        print(f"找到 {len(results)} 条工具知识")
+
+        if results:
+            print("\n前 3 条:")
+            for item in results[:3]:
+                if isinstance(item, dict):
+                    title = item.get('title', 'N/A')
+                    print(f"  - {item.get('id', 'N/A')}: {title[:40] if isinstance(title, str) else title}")
+                    print(f"    关联工具: {item.get('resource_ids', [])}")
+
+        return results
+    except Exception as e:
+        print(f"❌ 错误: {e}")
+        import traceback
+        traceback.print_exc()
+        return []
+
+
+def main():
+    """运行所有测试"""
+    print(f"KnowHub API: {KNOWHUB_API}")
+    print("=" * 60)
+
+    # 1. 列出所有资源
+    tools = test_list_all_resources()
+
+    # 2. 获取 ComfyUI 工具详情(验证 toolhub_items)
+    test_get_tool_detail("tools/image_gen/comfyui")
+
+    # 3. 获取第一个工具的详情
+    if tools:
+        first_tool_id = tools[0]["id"]
+        if first_tool_id != "tools/image_gen/comfyui":
+            test_get_tool_detail(first_tool_id)
+
+    # 3. 按分类搜索
+    test_search_tools_by_category("plugin")
+
+    # 4. 搜索工具相关知识
+    test_search_knowledge_by_tool("IP-Adapter")
+
+    # 5. 列出所有工具知识
+    test_list_tool_knowledge()
+
+    print("\n" + "=" * 60)
+    print("测试完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 24 - 0
tests/test_router_api.py

@@ -9,6 +9,9 @@
     uv run python tests/test_router_api.py --stitch                      # 测试图片拼接
     uv run python tests/test_router_api.py --create                      # 默认任务
     uv run python tests/test_router_api.py --create image_stitcher       # 指定任务文件
+    uv run python tests/test_router_api.py --launch-env                  # 创建 RunComfy 启动环境工具
+    uv run python tests/test_router_api.py --run-only                    # 创建 RunComfy 任务执行工具
+    uv run python tests/test_router_api.py --stop-env                    # 创建 RunComfy 环境销毁工具
 """
 
 import argparse
@@ -242,6 +245,12 @@ def main():
                         help="test image stitcher with sample images")
     parser.add_argument("--create", nargs="?", const="", metavar="TASK_NAME",
                         help="create tool, optional task file name")
+    parser.add_argument("--launch-env", action="store_true",
+                        help="create RunComfy launch env tool (runcomfy_launch_env)")
+    parser.add_argument("--run-only", action="store_true",
+                        help="create RunComfy run only tool (runcomfy_run_only)")
+    parser.add_argument("--stop-env", action="store_true",
+                        help="create RunComfy stop env tool (runcomfy_stop_env)")
     args = parser.parse_args()
 
     print(f"Target: {BASE_URL}\n")
@@ -277,6 +286,21 @@ def main():
         test_create_tool(args.create or None)
         ran_any = True
 
+    if args.launch_env:
+        print()
+        test_create_tool("runcomfy_launch_env")
+        ran_any = True
+
+    if args.run_only:
+        print()
+        test_create_tool("runcomfy_run_only")
+        ran_any = True
+
+    if args.stop_env:
+        print()
+        test_create_tool("runcomfy_stop_env")
+        ran_any = True
+
     if not ran_any:
         print()
         print("No test specified. Available options:")

+ 1 - 1
tools/local/launch_comfy_env/.python-version

@@ -1 +1 @@
-3.11
+3.12

+ 50 - 0
tools/local/launch_comfy_env/comfy_launcher.py

@@ -0,0 +1,50 @@
+import os
+import time
+import requests
+
+def launch_comfy_server(version_id: str, server_type: str, duration: int, timeout: int = 300):
+    """Launch RunComfy server and wait until ready"""
+    user_id = os.getenv("RUNCOMFY_USER_ID")
+    api_token = os.getenv("API_TOKEN")
+    
+    if not user_id or not api_token:
+        raise ValueError("RUNCOMFY_USER_ID and API_TOKEN must be set")
+    
+    # Create server
+    url = f"https://beta-api.runcomfy.net/prod/api/users/{user_id}/servers"
+    headers = {"Authorization": f"Bearer {api_token}"}
+    payload = {
+        "workflow_version_id": version_id,
+        "server_type": server_type,
+        "estimated_duration": duration
+    }
+    
+    resp = requests.post(url, json=payload, headers=headers)
+    resp.raise_for_status()
+    data = resp.json()
+    server_id = data["server_id"]
+    
+    # Poll until ready
+    check_url = f"{url}/{server_id}"
+    start_time = time.time()
+    
+    while time.time() - start_time < timeout:
+        resp = requests.get(check_url, headers=headers)
+        resp.raise_for_status()
+        server_data = resp.json()
+        
+        if server_data["current_status"] == "Ready":
+            return {
+                "server_id": server_id,
+                "comfy_url": server_data["main_service_url"],
+                "status": "Ready",
+                "usage_instruction": (
+                    f"请使用 run_comfy_workflow 工具,并传入此 server_id ({server_id}) "
+                    "以及你的 workflow_api.json 来生成图片。"
+                    "用完后请务必调用 stop_comfy_env 工具关闭机器。"
+                )
+            }
+        
+        time.sleep(5)
+    
+    raise TimeoutError(f"Server {server_id} not ready within {timeout}s")

+ 17 - 4
tools/local/launch_comfy_env/main.py

@@ -1,6 +1,19 @@
-def main():
-    print("Hello from launch-comfy-env!")
+from fastapi import FastAPI
+from pydantic import BaseModel
+from typing import Optional
+from comfy_launcher import launch_comfy_server
 
+app = FastAPI()
 
-if __name__ == "__main__":
-    main()
+class LaunchRequest(BaseModel):
+    version_id: Optional[str] = "90f77137-ba75-400d-870f-204c614ae8a3"
+    server_type: Optional[str] = "medium"
+    duration: Optional[int] = 3600
+
+@app.post("/launch")
+def launch(req: LaunchRequest):
+    return launch_comfy_server(
+        version_id=req.version_id,
+        server_type=req.server_type,
+        duration=req.duration
+    )

+ 6 - 2
tools/local/launch_comfy_env/pyproject.toml

@@ -3,5 +3,9 @@ name = "launch-comfy-env"
 version = "0.1.0"
 description = "Add your description here"
 readme = "README.md"
-requires-python = ">=3.11"
-dependencies = []
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.135.1",
+    "requests>=2.32.5",
+    "uvicorn>=0.42.0",
+]

+ 11 - 0
tools/local/launch_comfy_env/tests/last_run.log

@@ -0,0 +1,11 @@
+Command: python tests/test_launch.py
+Exit Code: 0
+--- STDOUT ---
+[OK] All imports successful
+[OK] LaunchRequest model validated
+[OK] FastAPI routes configured
+
+[OK] All unit tests passed
+Note: Real API calls require RUNCOMFY_USER_ID and API_TOKEN environment variables
+
+--- STDERR ---

+ 33 - 0
tools/local/launch_comfy_env/tests/run_comfy/launch_comfy_env.py

@@ -0,0 +1,33 @@
+import os
+import sys
+import argparse
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
+
+from comfy_launcher import launch_comfy_server
+
+def main():
+    parser = argparse.ArgumentParser(description='Launch RunComfy environment')
+    parser.add_argument('--version-id', default='90f77137-ba75-400d-870f-204c614ae8a3')
+    parser.add_argument('--server-type', default='medium')
+    parser.add_argument('--duration', type=int, default=3600)
+    parser.add_argument('--timeout', type=int, default=300)
+    
+    args = parser.parse_args()
+    
+    result = launch_comfy_server(
+        version_id=args.version_id,
+        server_type=args.server_type,
+        duration=args.duration,
+        timeout=args.timeout
+    )
+    
+    print(f"Server launched successfully!")
+    print(f"Server ID: {result['server_id']}")
+    print(f"ComfyUI URL: {result['comfy_url']}")
+    print(f"Status: {result['status']}")
+    print(f"\n{result['usage_instruction']}")
+
+if __name__ == '__main__':
+    main()

+ 41 - 0
tools/local/launch_comfy_env/tests/test_launch.py

@@ -0,0 +1,41 @@
+"""
+Unit test for launch_comfy_env
+Tests code structure without making real API calls
+"""
+import sys
+import os
+
+# Add parent directory to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Test imports
+try:
+    from comfy_launcher import launch_comfy_server
+    from main import app, LaunchRequest
+    print("[OK] All imports successful")
+except Exception as e:
+    print(f"[FAIL] Import failed: {e}")
+    sys.exit(1)
+
+# Test data models
+try:
+    req = LaunchRequest()
+    assert req.version_id == "90f77137-ba75-400d-870f-204c614ae8a3"
+    assert req.server_type == "medium"
+    assert req.duration == 3600
+    print("[OK] LaunchRequest model validated")
+except Exception as e:
+    print(f"[FAIL] Model validation failed: {e}")
+    sys.exit(1)
+
+# Test FastAPI app structure
+try:
+    routes = [route.path for route in app.routes]
+    assert "/launch" in routes
+    print("[OK] FastAPI routes configured")
+except Exception as e:
+    print(f"[FAIL] Route validation failed: {e}")
+    sys.exit(1)
+
+print("\n[OK] All unit tests passed")
+print("Note: Real API calls require RUNCOMFY_USER_ID and API_TOKEN environment variables")

+ 1 - 0
tools/local/runcomfy_stop_env/.python-version

@@ -0,0 +1 @@
+3.12

+ 0 - 0
tools/local/runcomfy_stop_env/README.md


+ 22 - 0
tools/local/runcomfy_stop_env/main.py

@@ -0,0 +1,22 @@
+from fastapi import FastAPI
+from pydantic import BaseModel
+from stop_service import stop_server
+
+app = FastAPI(title="RunComfy Stop Service")
+
+class StopRequest(BaseModel):
+    server_id: str
+
+class StopResponse(BaseModel):
+    server_id: str
+    status: str
+    message: str
+
+@app.post("/stop", response_model=StopResponse)
+async def stop_endpoint(request: StopRequest):
+    result = stop_server(request.server_id)
+    return result
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=8000)

+ 12 - 0
tools/local/runcomfy_stop_env/pyproject.toml

@@ -0,0 +1,12 @@
+[project]
+name = "runcomfy-stop-env"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.135.1",
+    "python-dotenv>=1.2.2",
+    "requests>=2.32.5",
+    "uvicorn>=0.42.0",
+]

+ 49 - 0
tools/local/runcomfy_stop_env/stop_service.py

@@ -0,0 +1,49 @@
+import os
+import requests
+from dotenv import load_dotenv
+
+load_dotenv()
+
+BASE_URL = "https://beta-api.runcomfy.net/prod/api"
+USER_ID = os.getenv("RUNCOMFY_USER_ID")
+API_TOKEN = os.getenv("API_TOKEN")
+
+def stop_server(server_id: str) -> dict:
+    """Stop RunComfy server instance"""
+    if not USER_ID or not API_TOKEN:
+        return {
+            "server_id": server_id,
+            "status": "Error",
+            "message": "Missing RUNCOMFY_USER_ID or API_TOKEN environment variables"
+        }
+    
+    headers = {"Authorization": f"Bearer {API_TOKEN}"}
+    url = f"{BASE_URL}/users/{USER_ID}/servers/{server_id}"
+    
+    try:
+        resp = requests.delete(url, headers=headers, timeout=30)
+        
+        if resp.status_code == 200:
+            return {
+                "server_id": server_id,
+                "status": "Deleted",
+                "message": f"Server {server_id} successfully stopped"
+            }
+        elif resp.status_code == 404:
+            return {
+                "server_id": server_id,
+                "status": "NotFound",
+                "message": f"Server {server_id} not found or already deleted"
+            }
+        else:
+            return {
+                "server_id": server_id,
+                "status": "Error",
+                "message": f"HTTP {resp.status_code}: {resp.text}"
+            }
+    except Exception as e:
+        return {
+            "server_id": server_id,
+            "status": "Error",
+            "message": f"Request failed: {str(e)}"
+        }

+ 13 - 0
tools/local/runcomfy_stop_env/tests/last_run.log

@@ -0,0 +1,13 @@
+Command: python tests/test_stop.py
+Exit Code: 0
+--- STDOUT ---
+Testing stop server: test-server-12345
+
+Result:
+  server_id: test-server-12345
+  status: Deleted
+  message: Server test-server-12345 successfully stopped
+
+Test passed: Response format is correct
+
+--- STDERR ---

+ 28 - 0
tools/local/runcomfy_stop_env/tests/test_stop.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Test RunComfy Stop Service"""
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from stop_service import stop_server
+
+def test_stop_server():
+    """Test stop server functionality"""
+    test_server_id = "test-server-12345"
+    
+    print(f"Testing stop server: {test_server_id}")
+    result = stop_server(test_server_id)
+    
+    print(f"\nResult:")
+    print(f"  server_id: {result['server_id']}")
+    print(f"  status: {result['status']}")
+    print(f"  message: {result['message']}")
+    
+    assert "server_id" in result
+    assert "status" in result
+    assert "message" in result
+    
+    print("\nTest passed: Response format is correct")
+
+if __name__ == "__main__":
+    test_stop_server()

+ 1 - 0
tools/local/task_0cd69d84/.python-version

@@ -0,0 +1 @@
+3.12

+ 0 - 0
tools/local/task_0cd69d84/README.md


+ 140 - 0
tools/local/task_0cd69d84/main.py

@@ -0,0 +1,140 @@
+"""RunComfy Workflow HTTP API"""
+
+import base64
+import json
+import os
+import uuid
+from typing import Optional
+
+import requests
+import websocket
+from dotenv import load_dotenv
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel
+
+load_dotenv()
+
+app = FastAPI(title="RunComfy Workflow API")
+
+BASE_URL = "https://beta-api.runcomfy.net/prod/api"
+USER_ID = os.getenv("RUNCOMFY_USER_ID")
+API_TOKEN = os.getenv("API_TOKEN")
+
+HEADERS = {
+    "Authorization": f"Bearer {API_TOKEN}",
+    "Content-Type": "application/json",
+}
+
+SUBDIR_UPLOAD_MAP = {
+    "images": {"type": "input", "subfolder": ""},
+    "loras": {"type": "input", "subfolder": "loras"},
+    "checkpoints": {"type": "input", "subfolder": "checkpoints"},
+    "vae": {"type": "input", "subfolder": "vae"},
+}
+
+
+class InputFile(BaseModel):
+    filename: str
+    type: str
+    base64_data: str
+
+
+class WorkflowRequest(BaseModel):
+    server_id: str
+    workflow_api: dict
+    input_files: Optional[list[InputFile]] = None
+
+
+class WorkflowResponse(BaseModel):
+    prompt_id: str
+    images: list[str]
+    status: str
+    server_id: str
+
+
+def get_server_url(server_id: str) -> str:
+    resp = requests.get(f"{BASE_URL}/users/{USER_ID}/servers/{server_id}", headers=HEADERS)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("current_status") != "Ready":
+        raise Exception(f"机器未就绪: {data.get('current_status')}")
+    return data["main_service_url"].rstrip("/")
+
+
+def upload_file_from_base64(comfy_url: str, filename: str, base64_data: str, file_type: str, subfolder: str):
+    file_bytes = base64.b64decode(base64_data)
+    files = [("image", (filename, file_bytes, "application/octet-stream"))]
+    data = {"overwrite": "true", "type": file_type, "subfolder": subfolder}
+    resp = requests.post(f"{comfy_url}/upload/image", data=data, files=files)
+    resp.raise_for_status()
+    return resp.json()["name"]
+
+
+def submit_prompt(comfy_url: str, workflow_api: dict, client_id: str) -> str:
+    payload = {"prompt": workflow_api, "client_id": client_id}
+    resp = requests.post(f"{comfy_url}/prompt", json=payload)
+    resp.raise_for_status()
+    return resp.json()["prompt_id"]
+
+
+def wait_for_completion(comfy_url: str, client_id: str, prompt_id: str, timeout: int = 600):
+    scheme = "wss" if comfy_url.startswith("https") else "ws"
+    ws_url = f"{scheme}://{comfy_url.split('://', 1)[-1]}/ws?clientId={client_id}"
+    ws = websocket.WebSocket()
+    ws.settimeout(timeout)
+    ws.connect(ws_url)
+    try:
+        while True:
+            out = ws.recv()
+            if not out or isinstance(out, bytes):
+                continue
+            msg = json.loads(out)
+            if msg.get("type") == "executing":
+                data = msg.get("data", {})
+                if data.get("prompt_id") == prompt_id and data.get("node") is None:
+                    break
+            elif msg.get("type") == "execution_error":
+                if msg.get("data", {}).get("prompt_id") == prompt_id:
+                    raise Exception(f"执行错误: {msg['data'].get('exception_message')}")
+    finally:
+        ws.close()
+
+
+def download_images_as_base64(comfy_url: str, prompt_id: str) -> list[str]:
+    resp = requests.get(f"{comfy_url}/history/{prompt_id}")
+    resp.raise_for_status()
+    outputs = resp.json().get(prompt_id, {}).get("outputs", {})
+    images = []
+    for node_output in outputs.values():
+        if "images" in node_output:
+            for img in node_output["images"]:
+                params = {"filename": img["filename"], "subfolder": img.get("subfolder", ""),
+                         "type": img.get("type", "output")}
+                resp = requests.get(f"{comfy_url}/view", params=params)
+                resp.raise_for_status()
+                images.append(base64.b64encode(resp.content).decode())
+    return images
+
+
+@app.post("/run", response_model=WorkflowResponse)
+async def run_workflow(request: WorkflowRequest):
+    try:
+        comfy_url = get_server_url(request.server_id)
+        client_id = str(uuid.uuid4())
+        if request.input_files:
+            for file in request.input_files:
+                mapping = SUBDIR_UPLOAD_MAP.get(file.type, {"type": "input", "subfolder": file.type})
+                upload_file_from_base64(comfy_url, file.filename, file.base64_data, 
+                                       mapping["type"], mapping["subfolder"])
+        prompt_id = submit_prompt(comfy_url, request.workflow_api, client_id)
+        wait_for_completion(comfy_url, client_id, prompt_id)
+        images = download_images_as_base64(comfy_url, prompt_id)
+        return WorkflowResponse(prompt_id=prompt_id, images=images, status="Success", server_id=request.server_id)
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+if __name__ == "__main__":
+    import uvicorn
+    port = int(os.getenv("PORT", "8000"))
+    uvicorn.run(app, host="0.0.0.0", port=port)

+ 13 - 0
tools/local/task_0cd69d84/pyproject.toml

@@ -0,0 +1,13 @@
+[project]
+name = "task-0cd69d84"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.135.1",
+    "python-dotenv>=1.2.2",
+    "requests>=2.32.5",
+    "uvicorn>=0.42.0",
+    "websocket-client>=1.9.0",
+]

+ 5 - 0
tools/local/task_0cd69d84/tests/last_run.log

@@ -0,0 +1,5 @@
+Command: python -m py_compile tests/run_comfy/run_workflow_only.py
+Exit Code: 0
+--- STDOUT ---
+
+--- STDERR ---

+ 193 - 0
tools/local/task_0cd69d84/tests/run_comfy/run_workflow_only.py

@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""RunComfy Workflow Executor (仅执行,不启动/关闭机器)"""
+
+import argparse
+import json
+import os
+import sys
+import urllib.parse
+import uuid
+from pathlib import Path
+
+import requests
+import websocket
+from dotenv import load_dotenv
+
+load_dotenv()
+
+BASE_URL = "https://beta-api.runcomfy.net/prod/api"
+USER_ID = os.getenv("RUNCOMFY_USER_ID")
+API_TOKEN = os.getenv("API_TOKEN")
+HEADERS = {"Authorization": f"Bearer {API_TOKEN}", "Content-Type": "application/json"}
+
+SUBDIR_UPLOAD_MAP = {
+    "images": {"type": "input", "subfolder": ""},
+    "loras": {"type": "input", "subfolder": "loras"},
+    "checkpoints": {"type": "input", "subfolder": "checkpoints"},
+    "vae": {"type": "input", "subfolder": "vae"},
+    "controlnet": {"type": "input", "subfolder": "controlnet"},
+    "upscale": {"type": "input", "subfolder": "upscale_models"},
+}
+
+
+def get_server_url(server_id: str) -> str:
+    resp = requests.get(f"{BASE_URL}/users/{USER_ID}/servers/{server_id}", headers=HEADERS)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("current_status") != "Ready":
+        raise Exception(f"机器未就绪: {data.get('current_status')}")
+    return data["main_service_url"].rstrip("/")
+
+
+def upload_file(comfy_url: str, file_path: Path, file_type: str = "input", subfolder: str = "") -> str:
+    with open(file_path, "rb") as f:
+        files = [("image", (file_path.name, f, "application/octet-stream"))]
+        data = {"overwrite": "true", "type": file_type, "subfolder": subfolder}
+        resp = requests.post(f"{comfy_url}/upload/image", data=data, files=files)
+    resp.raise_for_status()
+    server_name = resp.json()["name"]
+    print(f"  上传: {file_path.name} → {subfolder}/{server_name}" if subfolder else f"  上传: {file_path.name}")
+    return server_name
+
+
+def upload_input_dir(comfy_url: str, input_dir: Path) -> dict[str, str]:
+    if not input_dir.exists():
+        print(f"  input 目录不存在: {input_dir}")
+        return {}
+    uploaded = {}
+    ALL_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".mp4", ".avi", ".mov", ".webm", 
+                ".safetensors", ".ckpt", ".pt", ".pth", ".gguf"}
+    for f in input_dir.iterdir():
+        if f.is_file() and f.suffix.lower() in ALL_EXTS:
+            server_name = upload_file(comfy_url, f, "input", "")
+            uploaded[f.name] = server_name
+    for subdir in input_dir.iterdir():
+        if not subdir.is_dir():
+            continue
+        mapping = SUBDIR_UPLOAD_MAP.get(subdir.name, {"type": "input", "subfolder": subdir.name})
+        for f in subdir.iterdir():
+            if f.is_file() and f.suffix.lower() in ALL_EXTS:
+                server_name = upload_file(comfy_url, f, mapping["type"], mapping["subfolder"])
+                uploaded[f.name] = server_name
+    return uploaded
+
+
+def submit_prompt(comfy_url: str, workflow_api: dict, client_id: str) -> str:
+    payload = {"prompt": workflow_api, "client_id": client_id}
+    resp = requests.post(f"{comfy_url}/prompt", json=payload)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("node_errors"):
+        print(f"  节点错误: {data['node_errors']}")
+    print(f"任务已提交: {data['prompt_id']}")
+    return data["prompt_id"]
+
+
+def wait_for_completion(comfy_url: str, client_id: str, prompt_id: str, timeout: int = 600):
+    scheme = "wss" if comfy_url.startswith("https") else "ws"
+    ws_url = f"{scheme}://{comfy_url.split('://', 1)[-1]}/ws?clientId={client_id}"
+    print("WebSocket 监听中...")
+    ws = websocket.WebSocket()
+    ws.settimeout(timeout)
+    ws.connect(ws_url)
+    try:
+        while True:
+            out = ws.recv()
+            if not out or isinstance(out, bytes):
+                continue
+            msg = json.loads(out)
+            if msg.get("type") == "executing":
+                data = msg.get("data", {})
+                if data.get("prompt_id") == prompt_id and data.get("node") is None:
+                    print("  执行完成")
+                    break
+                if data.get("node"):
+                    print(f"  执行节点: {data['node']}")
+            elif msg.get("type") == "progress":
+                data = msg.get("data", {})
+                print(f"  进度: {data.get('value', 0)}/{data.get('max', 1)}")
+            elif msg.get("type") == "execution_error":
+                if msg.get("data", {}).get("prompt_id") == prompt_id:
+                    raise Exception(f"执行错误: {msg['data'].get('exception_message')}")
+    finally:
+        ws.close()
+
+
+def download_outputs(comfy_url: str, prompt_id: str, output_dir: Path) -> list[str]:
+    resp = requests.get(f"{comfy_url}/history/{prompt_id}")
+    resp.raise_for_status()
+    outputs = resp.json().get(prompt_id, {}).get("outputs", {})
+    output_dir.mkdir(parents=True, exist_ok=True)
+    saved = []
+    for node_output in outputs.values():
+        if "images" in node_output:
+            for img in node_output["images"]:
+                params = {"filename": img["filename"], "subfolder": img.get("subfolder", ""),
+                         "type": img.get("temp") or img.get("type", "output")}
+                resp = requests.get(f"{comfy_url}/view?{urllib.parse.urlencode(params)}")
+                resp.raise_for_status()
+                out_path = output_dir / img["filename"]
+                out_path.write_bytes(resp.content)
+                print(f"  图片: {out_path}")
+                saved.append(str(out_path))
+        if "gifs" in node_output:
+            for video in node_output["gifs"]:
+                params = {"filename": video["filename"], "subfolder": video.get("subfolder", ""),
+                         "format": video.get("format", "mp4")}
+                resp = requests.get(f"{comfy_url}/view?{urllib.parse.urlencode(params)}")
+                resp.raise_for_status()
+                out_path = output_dir / video["filename"]
+                out_path.write_bytes(resp.content)
+                print(f"  视频: {out_path}")
+                saved.append(str(out_path))
+    return saved
+
+
+def main():
+    parser = argparse.ArgumentParser(description="RunComfy workflow executor (不启动/关闭机器)")
+    parser.add_argument("--server-id", required=True, help="已启动的机器 ID")
+    parser.add_argument("--workflow", required=True, help="workflow_api.json 路径")
+    parser.add_argument("--input-dir", default="input", help="输入文件目录,默认 input/")
+    parser.add_argument("--output-dir", default="output", help="结果下载目录,默认 output/")
+    parser.add_argument("--skip-upload", action="store_true", help="跳过文件上传")
+    args = parser.parse_args()
+    
+    if not USER_ID or not API_TOKEN:
+        print("ERROR: 请设置 RUNCOMFY_USER_ID 和 API_TOKEN 环境变量")
+        sys.exit(1)
+    
+    workflow_path = Path(args.workflow)
+    if not workflow_path.exists():
+        print(f"ERROR: 文件不存在: {workflow_path}")
+        sys.exit(1)
+    
+    with open(workflow_path, "r", encoding="utf-8") as f:
+        workflow_api = json.load(f)
+    
+    client_id = str(uuid.uuid4())
+    
+    try:
+        print(f"验证机器状态: {args.server_id}")
+        comfy_url = get_server_url(args.server_id)
+        print(f"  ComfyUI URL: {comfy_url}")
+        
+        if not args.skip_upload:
+            print(f"\n上传 input 目录: {args.input_dir}")
+            upload_input_dir(comfy_url, Path(args.input_dir))
+        
+        print(f"\n提交 workflow...")
+        prompt_id = submit_prompt(comfy_url, workflow_api, client_id)
+        wait_for_completion(comfy_url, client_id, prompt_id)
+        
+        print(f"\n下载结果...")
+        saved = download_outputs(comfy_url, prompt_id, Path(args.output_dir))
+        print(f"\n完成,共 {len(saved)} 个文件")
+        print(f"注意: 机器 {args.server_id} 未自动关闭,请手动处理")
+    except Exception as e:
+        print(f"\n错误: {e}")
+        print(f"机器 {args.server_id} 未自动关闭,请手动处理")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 69 - 0
uv.lock

@@ -5,7 +5,10 @@ requires-python = ">=3.12"
 [manifest]
 members = [
     "image-stitcher",
+    "launch-comfy-env",
     "liblibai-controlnet",
+    "runcomfy-stop-env",
+    "task-0cd69d84",
     "tool-agent",
 ]
 
@@ -446,6 +449,23 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
 ]
 
+[[package]]
+name = "launch-comfy-env"
+version = "0.1.0"
+source = { virtual = "tools/local/launch_comfy_env" }
+dependencies = [
+    { name = "fastapi" },
+    { name = "requests" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.135.1" },
+    { name = "requests", specifier = ">=2.32.5" },
+    { name = "uvicorn", specifier = ">=0.42.0" },
+]
+
 [[package]]
 name = "liblibai-controlnet"
 version = "0.1.0"
@@ -956,6 +976,25 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
 ]
 
+[[package]]
+name = "runcomfy-stop-env"
+version = "0.1.0"
+source = { virtual = "tools/local/runcomfy_stop_env" }
+dependencies = [
+    { name = "fastapi" },
+    { name = "python-dotenv" },
+    { name = "requests" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.135.1" },
+    { name = "python-dotenv", specifier = ">=1.2.2" },
+    { name = "requests", specifier = ">=2.32.5" },
+    { name = "uvicorn", specifier = ">=0.42.0" },
+]
+
 [[package]]
 name = "sse-starlette"
 version = "3.3.3"
@@ -982,6 +1021,27 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
 ]
 
+[[package]]
+name = "task-0cd69d84"
+version = "0.1.0"
+source = { virtual = "tools/local/task_0cd69d84" }
+dependencies = [
+    { name = "fastapi" },
+    { name = "python-dotenv" },
+    { name = "requests" },
+    { name = "uvicorn" },
+    { name = "websocket-client" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.135.1" },
+    { name = "python-dotenv", specifier = ">=1.2.2" },
+    { name = "requests", specifier = ">=2.32.5" },
+    { name = "uvicorn", specifier = ">=0.42.0" },
+    { name = "websocket-client", specifier = ">=1.9.0" },
+]
+
 [[package]]
 name = "tool-agent"
 version = "0.1.0"
@@ -1176,6 +1236,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
 ]
 
+[[package]]
+name = "websocket-client"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
+]
+
 [[package]]
 name = "websockets"
 version = "16.0"

Некоторые файлы не были показаны из-за большого количества измененных файлов