"""FlowDeck — Agent tool registry (v4.14.0). The agent never re-invents FlowDeck: each tool is a thin wrapper over the same operations the human-facing routers perform (create collection/page/property, manage views, dependencies, Gitea issues, templates, workspaces & documents). Every mutating tool returns an *undo snapshot* so AgentEngine can journal and roll back each action. Since v4.14.0 the registry also covers *documents* (Notion-style pages that live inside a workspace, table ``pages``) and lets the agent read the list of workspaces — fixing the case where "crée un document dans le workspace X" used to end with no action. """ from __future__ import annotations import json import logging import re import sqlite3 from dataclasses import dataclass, field from datetime import UTC from typing import Any from app.db import get_conn from app.services.collection_lifecycle import delete_collections logger = logging.getLogger(__name__) @dataclass class ToolResult: status: str # success | error | reverted tool: str target_type: str = "" target_id: Any = None message: str = "" data: dict = field(default_factory=dict) undo: dict = field(default_factory=dict) # snapshot to restore on rollback class Tool: name: str = "" description: str = "" parameters: dict = field(default_factory=dict) async def execute(self, args: dict, *, user_id: int | None = None) -> ToolResult: # pragma: no cover raise NotImplementedError # ══════════════════════════ Read tools ══════════════════════════ class SearchWorkspace(Tool): name = "search_workspace" description = ("Recherche full-text (par titre) dans tout FlowDeck : collections, pages de " "collection, documents (pages éditeur) et espaces de travail.") parameters = { "type": "object", "properties": {"query": {"type": "string", "description": "terme recherché"}}, "required": ["query"], } async def execute(self, args, *, user_id=None) -> ToolResult: q = f"%{args.get('query', '').strip()}%" with get_conn() as conn: colls = conn.execute("SELECT id, name, icon FROM collections WHERE name LIKE ? ORDER BY name", (q,)).fetchall() pages = conn.execute("SELECT id, collection_id, title FROM collection_pages WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,)).fetchall() docs = conn.execute( "SELECT id, title, workspace_id, content_format FROM pages " "WHERE deleted_at IS NULL AND title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,), ).fetchall() workspaces = conn.execute( "SELECT id, name FROM workspaces WHERE name LIKE ? ORDER BY name", (q,), ).fetchall() return ToolResult( status="success", tool=self.name, target_type="search", data={ "collections": [dict(c) for c in colls], "pages": [dict(p) for p in pages], "documents": [dict(d) for d in docs], "workspaces": [dict(w) for w in workspaces], }, message=(f"{len(colls)} collection(s), {len(pages)} page(s), " f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(s)"), ) class ReadCollection(Tool): name = "read_collection" description = "Lit le schéma (propriétés + vues) d'une collection." parameters = { "type": "object", "properties": {"collection_id": {"type": "integer"}}, "required": ["collection_id"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid = args.get("collection_id") with get_conn() as conn: coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() if not coll: return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable") props = conn.execute("SELECT id, name, prop_type, options_json FROM collection_properties WHERE collection_id=? ORDER BY position", (cid,)).fetchall() views = conn.execute("SELECT id, name, view_type, config_json FROM collection_views WHERE collection_id=? ORDER BY position", (cid,)).fetchall() pages = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position", (cid,)).fetchall() return ToolResult( status="success", tool=self.name, target_type="collection", target_id=cid, data={ "collection": dict(coll), "properties": [dict(p) for p in props], "views": [dict(v) for v in views], "pages": [dict(p) for p in pages], }, ) class ReadPage(Tool): name = "read_page" description = "Lit une page d'une collection (propriétés + valeurs)." parameters = { "type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"], } async def execute(self, args, *, user_id=None) -> ToolResult: pid = args.get("page_id") with get_conn() as conn: page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() if not page: return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable") deps = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (pid,)).fetchall() return ToolResult( status="success", tool=self.name, target_type="page", target_id=pid, data={"page": dict(page), "dependencies": [dict(d) for d in deps]}, ) class ReadWorkspaces(Tool): name = "read_workspaces" description = ("Liste les espaces de travail accessibles (id, nom, rôle) avec le nombre de " "documents et de collections qu'ils contiennent — utile pour savoir où créer " "ou chercher un document.") parameters = { "type": "object", "properties": {"query": {"type": "string", "description": "filtre optionnel sur le nom"}}, } async def execute(self, args, *, user_id=None) -> ToolResult: query = (args.get("query") or "").strip() base_sql = ( "SELECT w.id, w.name, {role} AS role, " "(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count, " "(SELECT COUNT(*) FROM collections c WHERE c.workspace_id=w.id) AS collection_count " "FROM workspaces w {join} {where} ORDER BY w.name" ) with get_conn() as conn: if user_id is None: rows = conn.execute( base_sql.format(role="'owner'", join="", where=""), [] ).fetchall() else: rows = conn.execute( base_sql.format( role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)", join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?", where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL", ), (user_id, user_id, user_id), ).fetchall() data = [dict(r) for r in rows] if query: q = query.lower() data = [w for w in data if q in str(w.get("name", "")).lower()] return ToolResult( status="success", tool=self.name, target_type="workspaces", data={"workspaces": data}, message=f"{len(data)} espace(s) de travail", ) class ReadDocument(Tool): name = "read_document" description = "Lit un document (page éditeur) : titre, contenu et métadonnées." parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]} async def execute(self, args, *, user_id=None) -> ToolResult: pid = args.get("page_id") with get_conn() as conn: doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone() if not doc: return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable") return ToolResult( status="success", tool=self.name, target_type="document", target_id=pid, data={"document": dict(doc)}, ) # ══════════════════════════ Write tools (with undo) ══════════════════════════ class CreateCollection(Tool): name = "create_collection" description = "Crée une collection (base de données) avec sa vue par défaut." parameters = { "type": "object", "properties": { "name": {"type": "string"}, "description": {"type": "string"}, "icon": {"type": "string"}, }, "required": ["name"], } async def execute(self, args, *, user_id=None) -> ToolResult: name = (args.get("name") or "").strip() if not name: return ToolResult(status="error", tool=self.name, message="name est requis") description = args.get("description", "") icon = args.get("icon", "📋") with get_conn() as conn: cur = conn.execute( "INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,'[]')", (name, description, icon), ) cid = cur.lastrowid conn.execute( "INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})), ) conn.commit() return ToolResult( status="success", tool=self.name, target_type="collection", target_id=cid, data={"collection_id": cid, "name": name}, undo={"action": "delete", "table": "collections", "id": cid}, ) class CreateView(Tool): name = "create_view" description = "Crée une vue (table/board/calendar/gallery/list/timeline/gantt/chart/form/map/feed) sur une collection." parameters = { "type": "object", "properties": { "collection_id": {"type": "integer"}, "view_type": {"type": "string"}, "name": {"type": "string"}, }, "required": ["collection_id", "view_type"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid, vtype = args.get("collection_id"), args.get("view_type", "table") name = args.get("name", vtype.title()) with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone() if not coll: return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (cid,)).fetchone()[0] cur = conn.execute( "INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?,?,?,?,?)", (cid, name, vtype, json.dumps({}), max_pos), ) conn.commit() return ToolResult( status="success", tool=self.name, target_type="view", target_id=cur.lastrowid, data={"view_id": cur.lastrowid, "collection_id": cid, "view_type": vtype}, undo={"action": "delete", "table": "collection_views", "id": cur.lastrowid}, ) class AddProperty(Tool): name = "add_property" description = "Ajoute une propriété typée à une collection." parameters = { "type": "object", "properties": { "collection_id": {"type": "integer"}, "name": {"type": "string"}, "prop_type": {"type": "string"}, "options": {"type": "array", "items": {"type": "string"}}, }, "required": ["collection_id", "name"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid = args.get("collection_id") name = (args.get("name") or "").strip() prop_type = args.get("prop_type", "text") options = args.get("options", []) if not name: return ToolResult(status="error", tool=self.name, message="name est requis") with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone() if not coll: return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0] try: cur = conn.execute( "INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?,?,?,?,?)", (cid, name, prop_type, json.dumps(options), max_pos), ) conn.commit() except sqlite3.IntegrityError: return ToolResult(status="error", tool=self.name, message=f"Propriété '{name}' existe déjà") return ToolResult( status="success", tool=self.name, target_type="property", target_id=cur.lastrowid, data={"property_id": cur.lastrowid, "name": name, "prop_type": prop_type}, undo={"action": "delete", "table": "collection_properties", "id": cur.lastrowid}, ) class CreatePage(Tool): name = "create_page" description = "Crée une page dans une collection avec les valeurs de ses propriétés." parameters = { "type": "object", "properties": { "collection_id": {"type": "integer"}, "title": {"type": "string"}, "properties": {"type": "object", "description": "map name→valeur"}, }, "required": ["collection_id", "title"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid = args.get("collection_id") title = (args.get("title") or "").strip() if not title: return ToolResult(status="error", tool=self.name, message="title est requis") with get_conn() as conn: coll = conn.execute("SELECT id, is_locked FROM collections WHERE id=?", (cid,)).fetchone() if not coll: return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable") if coll["is_locked"]: return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0] props = self._resolve_properties(conn, cid, args.get("properties", {})) cur = conn.execute( "INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)", (cid, title, max_pos, json.dumps(props, ensure_ascii=False)), ) conn.commit() return ToolResult( status="success", tool=self.name, target_type="page", target_id=cur.lastrowid, data={"page_id": cur.lastrowid, "title": title}, undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid}, ) @staticmethod def _resolve_properties(conn, cid, values: dict) -> dict: """Map human property names → property ids for the JSON blob.""" props = conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (cid,)).fetchall() id_by_name = {r["name"]: r["id"] for r in props} out = {} for k, v in (values or {}).items(): key = id_by_name.get(k, k) if isinstance(v, list): out[str(key)] = v else: out[str(key)] = v return out class CreateDocument(Tool): name = "create_document" description = ("Crée un document (page éditeur type Notion) dans un espace de travail, ou à la " "racine. Le contenu initial est optionnel (Markdown).") parameters = { "type": "object", "properties": { "title": {"type": "string", "description": "titre du document"}, "content": {"type": "string", "description": "contenu initial en Markdown (optionnel)"}, "workspace_id": {"type": "integer", "description": "id de l'espace de travail cible (optionnel)"}, "workspace_name": {"type": "string", "description": "nom exact de l'espace de travail cible (optionnel)"}, "parent_id": {"type": "integer", "description": "document parent (sous-page) optionnel"}, }, "required": ["title"], } async def execute(self, args, *, user_id=None) -> ToolResult: title = (args.get("title") or "").strip() if not title: return ToolResult(status="error", tool=self.name, message="title est requis") content = (args.get("content") or "").strip() ws_id = args.get("workspace_id") ws_name = (args.get("workspace_name") or "").strip() parent_id = args.get("parent_id") or None with get_conn() as conn: workspace = None if ws_id is not None: workspace = conn.execute("SELECT * FROM workspaces WHERE id=?", (ws_id,)).fetchone() if not workspace: return ToolResult(status="error", tool=self.name, message=f"Workspace #{ws_id} introuvable") elif ws_name: workspace = conn.execute( "SELECT * FROM workspaces WHERE lower(name)=lower(?) ORDER BY id LIMIT 1", (ws_name,), ).fetchone() if not workspace: available = conn.execute("SELECT name FROM workspaces ORDER BY name").fetchall() names = ", ".join(r["name"] for r in available) or "aucun" return ToolResult( status="error", tool=self.name, message=f"Workspace « {ws_name} » introuvable. Disponibles : {names}", ) if parent_id: if not conn.execute("SELECT id FROM pages WHERE id=? AND deleted_at IS NULL", (parent_id,)).fetchone(): return ToolResult(status="error", tool=self.name, message=f"Document parent #{parent_id} introuvable") w = dict(workspace) if workspace else None ws_id_val = w["id"] if w else (ws_id if ws_id is not None else None) ws_key = (w["name"] if w else "") if ws_id_val is not None else "" if content: fmt, store = "markdown", content else: fmt, store = "blocks", "[]" cur = conn.execute( """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?,?,?,0)""", (ws_key, ws_id_val, title, store, fmt, "Private", parent_id), ) pid = cur.lastrowid conn.commit() loc = f" dans « {ws_key} »" if ws_key else "" return ToolResult( status="success", tool=self.name, target_type="document", target_id=pid, data={"document_id": pid, "title": title, "workspace_id": ws_id_val, "workspace": ws_key}, message=f"Document « {title} » créé{loc}", undo={"action": "delete", "table": "pages", "id": pid}, ) class UpdatePage(Tool): name = "update_page" description = "Modifie le titre et/ou les valeurs de propriétés d'une page." parameters = { "type": "object", "properties": { "page_id": {"type": "integer"}, "title": {"type": "string"}, "properties": {"type": "object"}, }, "required": ["page_id"], } async def execute(self, args, *, user_id=None) -> ToolResult: pid = args.get("page_id") with get_conn() as conn: page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() if not page: return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable") coll = conn.execute("SELECT is_locked FROM collections WHERE id=?", (page["collection_id"],)).fetchone() if coll and coll["is_locked"]: return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)") new_title = args.get("title", page["title"]) props = json.loads(page["property_values_json"]) if args.get("properties"): props.update(args["properties"]) conn.execute( "UPDATE collection_pages SET title=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_title, json.dumps(props, ensure_ascii=False), pid), ) conn.commit() return ToolResult( status="success", tool=self.name, target_type="page", target_id=pid, data={"page_id": pid, "title": new_title}, undo={"action": "update", "table": "collection_pages", "id": pid, "snapshot": json.loads(page["property_values_json"]), "title": page["title"]}, ) class WriteBlocks(Tool): name = "write_blocks" description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs " "et/ou renomme son titre. Chaque bloc est un objet JSON " "{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : " "paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, " "numbered_list, to_do, quote, divider, toggle, callout, code. " "Utilise `type: \"code\"` (avec `language`) pour un script ou du code " "multi-lignes, `heading_1`/`heading_2`/… pour les titres. " "Le champ contenant le texte s'appelle `content`, PAS `text`.") parameters = { "type": "object", "properties": { "page_id": {"type": "integer", "description": "id du document (page éditeur)"}, "blocks": {"type": "array", "description": "nouveau contenu en blocs (remplace le contenu). " "Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, " "{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"}, "title": {"type": "string", "description": "nouveau titre (optionnel)"}, }, "required": ["page_id"], } _VALID_BLOCK_TYPES = frozenset({ "paragraph", "heading_1", "heading_2", "heading_3", "heading_4", "bulleted_list", "numbered_list", "to_do", "toggle", "quote", "callout", "table_of_contents", "math", "columns", "divider", "code", "table", "button", "image", "embed", "bookmark", "video", "audio", "meeting", }) @classmethod def _normalize_blocks(cls, blocks: list) -> list: """Normalize blocks coming from the LLM so the editor can render them. Common LLM mistakes handled: - ``text`` instead of ``content`` - ``heading`` instead of ``heading_1`` / ``heading_2`` / … - ``bullet`` / ``list`` instead of ``bulleted_list`` - ``numbered`` instead of ``numbered_list`` - ``check`` / ``checkbox`` instead of ``to_do`` - ``code_block`` instead of ``code`` - ``h1`` … ``h6`` shorthand - Missing ``id`` fields (editor assigns them on load but we add them to keep the JSON self-contained) """ import uuid _TYPE_MAP = { "heading": "heading_1", "h1": "heading_1", "h2": "heading_2", "h3": "heading_3", "h4": "heading_4", "h5": "heading_4", "h6": "heading_4", "bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list", "numbered": "numbered_list", "numbered_list": "numbered_list", "check": "to_do", "checkbox": "to_do", "task": "to_do", "code_block": "code", "hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider", } out = [] for block in (blocks or []): if not isinstance(block, dict): continue b = dict(block) # --- normalize type --- raw_type = (b.get("type") or "paragraph").strip().lower() btype = _TYPE_MAP.get(raw_type, raw_type) if btype not in cls._VALID_BLOCK_TYPES: btype = "paragraph" b["type"] = btype # --- normalize content field (LLM often sends "text" instead of "content") --- if "content" not in b and "text" in b: b["content"] = b.pop("text") elif "content" not in b: # Try other common fields for alt in ("value", "body", "source"): if alt in b: b["content"] = b.pop(alt) break else: b.setdefault("content", "") # --- generate id if missing --- if not b.get("id"): b["id"] = f"b_{uuid.uuid4().hex[:12]}" out.append(b) return out async def execute(self, args, *, user_id=None) -> ToolResult: pid = args.get("page_id") has_blocks = "blocks" in args new_title = (args.get("title") or "").strip() if not has_blocks and not new_title: return ToolResult(status="error", tool=self.name, message="Fournir `blocks` et/ou `title`") with get_conn() as conn: page = conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone() if not page: return ToolResult(status="error", tool=self.name, message=f"Document (page éditeur) #{pid} introuvable") snapshot = {"content": page["content"], "content_format": page["content_format"], "title": page["title"]} final_title = new_title or page["title"] if has_blocks: normalized = self._normalize_blocks(args["blocks"]) conn.execute( "UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(normalized, ensure_ascii=False), final_title, pid), ) else: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (final_title, pid)) conn.commit() return ToolResult( status="success", tool=self.name, target_type="document", target_id=pid, data={"document_id": pid, "title": final_title, "blocks": len(args.get("blocks", []))}, undo={"action": "update", "table": "pages", "id": pid, "snapshot": snapshot}, ) class AddRelation(Tool): name = "add_relation" description = "Lie deux collections via une propriété relation (avec réciproque)." parameters = { "type": "object", "properties": { "collection_id": {"type": "integer"}, "related_collection_id": {"type": "integer"}, "name": {"type": "string"}, "reverse_name": {"type": "string"}, }, "required": ["collection_id", "related_collection_id", "name"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid = args.get("collection_id") rel = args.get("related_collection_id") name = (args.get("name") or "").strip() reverse = args.get("reverse_name", "") if not name or not rel: return ToolResult(status="error", tool=self.name, message="name et related_collection_id requis") with get_conn() as conn: for c in (cid, rel): if not conn.execute("SELECT id FROM collections WHERE id=?", (c,)).fetchone(): return ToolResult(status="error", tool=self.name, message=f"Collection #{c} introuvable") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0] cur = conn.execute( "INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?,?,?,?,?,?)", (cid, name, "relation", rel, reverse, max_pos), ) prop_id = cur.lastrowid conn.commit() return ToolResult( status="success", tool=self.name, target_type="property", target_id=prop_id, data={"property_id": prop_id, "relation": name}, undo={"action": "delete", "table": "collection_properties", "id": prop_id}, ) class CreateSubItem(Tool): name = "create_sub_item" description = "Crée un sous-élément (sub-item) sous une page." parameters = { "type": "object", "properties": {"collection_id": {"type": "integer"}, "page_id": {"type": "integer"}, "title": {"type": "string"}}, "required": ["collection_id", "page_id", "title"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid, pid = args.get("collection_id"), args.get("page_id") title = (args.get("title") or "").strip() with get_conn() as conn: parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (pid, cid)).fetchone() if not parent: return ToolResult(status="error", tool=self.name, message="Page parent introuvable") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?", (pid,)).fetchone()[0] cur = conn.execute( "INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?,?,?,?,?)", (cid, title, pid, max_pos, json.dumps({})), ) conn.commit() return ToolResult( status="success", tool=self.name, target_type="page", target_id=cur.lastrowid, data={"page_id": cur.lastrowid, "parent_id": pid}, undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid}, ) class AddDependency(Tool): name = "add_dependency" description = "Ajoute une dépendance (bloque) entre deux pages." parameters = { "type": "object", "properties": { "page_id": {"type": "integer"}, "dependency_id": {"type": "integer"}, "dependency_type": {"type": "string"}, }, "required": ["page_id", "dependency_id"], } async def execute(self, args, *, user_id=None) -> ToolResult: pid, dep = args.get("page_id"), args.get("dependency_id") dtype = args.get("dependency_type", "blocks") with get_conn() as conn: for p in (pid, dep): if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (p,)).fetchone(): return ToolResult(status="error", tool=self.name, message=f"Page #{p} introuvable") try: cur = conn.execute( "INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?,?,?)", (pid, dep, dtype), ) conn.commit() except sqlite3.IntegrityError: return ToolResult(status="error", tool=self.name, message="Dépendance déjà existante") return ToolResult( status="success", tool=self.name, target_type="dependency", target_id=cur.lastrowid, data={"dependency_id": cur.lastrowid, "page_id": pid}, undo={"action": "delete", "table": "page_dependencies", "id": cur.lastrowid}, ) class ApplyTemplate(Tool): name = "apply_template" description = "Applique un template de page dans une collection (crée une page)." parameters = { "type": "object", "properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}}, "required": ["collection_id", "template_id"], } async def execute(self, args, *, user_id=None) -> ToolResult: cid, tid = args.get("collection_id"), args.get("template_id") with get_conn() as conn: tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone() if not tmpl: return ToolResult(status="error", tool=self.name, message="Template introuvable") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0] cur = conn.execute( "INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)", (cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]), ) conn.commit() return ToolResult( status="success", tool=self.name, target_type="page", target_id=cur.lastrowid, data={"page_id": cur.lastrowid}, undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid}, ) # ══════════════════════════ Destructive tools ══════════════════════════ class DeleteDocument(Tool): name = "delete_document" description = "Supprime un document (page éditeur) — envoi à la corbeille (destructif, mode confirm requis)." parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]} async def execute(self, args, *, user_id=None) -> ToolResult: from datetime import datetime pid = args.get("page_id") with get_conn() as conn: doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone() if not doc: return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable") conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.now(UTC).replace(tzinfo=None).isoformat(), pid)) conn.commit() return ToolResult( status="success", tool=self.name, target_type="document", target_id=pid, message=f"Document #{pid} déplacé vers la corbeille", undo={"action": "softdelete", "table": "pages", "id": pid}, ) class DeletePage(Tool): name = "delete_page" description = "Supprime une page d'une collection (destructif, mode confirm requis)." parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]} async def execute(self, args, *, user_id=None) -> ToolResult: pid = args.get("page_id") with get_conn() as conn: page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() if not page: return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable") conn.execute("DELETE FROM collection_pages WHERE id=?", (pid,)) conn.commit() return ToolResult( status="success", tool=self.name, target_type="page", target_id=pid, message="Page supprimée", undo={"action": "insert", "table": "collection_pages", "snapshot": dict(page)}, ) class DeleteCollection(Tool): name = "delete_collection" description = "Supprime une collection (destructif, mode confirm requis)." parameters = {"type": "object", "properties": {"collection_id": {"type": "integer"}}, "required": ["collection_id"]} async def execute(self, args, *, user_id=None) -> ToolResult: cid = args.get("collection_id") with get_conn() as conn: coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() if not coll: return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable") delete_collections(conn, [cid]) conn.commit() return ToolResult( status="success", tool=self.name, target_type="collection", target_id=cid, message="Collection supprimée", undo={"action": "insert", "table": "collections", "snapshot": dict(coll)}, ) # ══════════════════════════ Gitea tools ══════════════════════════ class ReadGiteaIssues(Tool): name = "read_gitea_issues" description = "Lit les issues Gitea d'un repo (état, labels, milestones)." parameters = { "type": "object", "properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "state": {"type": "string"}}, "required": ["owner", "repo"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.auth.session import SessionManager from app.services.gitea_client import GiteaClient token = SessionManager.get_token(user_id) if user_id else None client = GiteaClient(user_token=token) owner, repo = args.get("owner"), args.get("repo") try: issues = await client.get_issues(owner, repo, state=args.get("state", "open")) except Exception as exc: # noqa: BLE001 return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}") return ToolResult( status="success", tool=self.name, target_type="issues", target_id=f"{owner}/{repo}", data={"issues": issues, "count": len(issues)}, message=f"{len(issues)} issues", ) class SyncGitea(Tool): name = "sync_gitea" description = "Synchronise les issues d'un repo Gitea vers une collection." parameters = { "type": "object", "properties": {"owner": {"type": "string"}, "repo": {"type": "string"}}, "required": ["owner", "repo"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.auth.session import SessionManager from app.services.collection_adapter import GiteaBoardCompat from app.services.gitea_client import GiteaClient token = SessionManager.get_token(user_id) if user_id else None client = GiteaClient(user_token=token) owner, repo = args.get("owner"), args.get("repo") try: issues = await client.get_issues(owner, repo, state="all") except Exception as exc: # noqa: BLE001 return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}") coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues) return ToolResult( status="success", tool=self.name, target_type="collection", target_id=coll_id, data={"collection_id": coll_id, "issues": len(issues)}, message=f"{len(issues)} issues synchronisées", undo={"action": "delete", "table": "collections", "id": coll_id} if coll_id else {}, ) class CreateGiteaIssue(Tool): name = "create_gitea_issue" description = "Crée une issue Gitea dans un repo." parameters = { "type": "object", "properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}}, "required": ["owner", "repo", "title"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.auth.session import SessionManager from app.services.gitea_client import GiteaClient token = SessionManager.get_token(user_id) if user_id else None client = GiteaClient(user_token=token) try: issue = await client.create_issue(args["owner"], args["repo"], args["title"], args.get("body", "")) except Exception as exc: # noqa: BLE001 return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}") return ToolResult( status="success", tool=self.name, target_type="issue", target_id=issue.get("number"), data={"issue": issue}, message=f"Issue #{issue.get('number')} créée", ) # ══════════════════════════ Web tools ══════════════════════════ # Outils réseau : recherche web, lecture d'une page, recherche GitHub. # Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL # (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée, # re-vérifiée à chaque saut de redirection. class WebSearch(Tool): name = "web_search" description = ( "Recherche sur le web (actualités, docs, libs, comparatifs) et " "renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de " "répondre sur un fait récent ou technique, puis CITE les URL obtenues." ) parameters = { "type": "object", "properties": { "query": {"type": "string", "description": "requête de recherche"}, "num_results": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"}, }, "required": ["query"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.services.web_search import available_providers, search_web query = str(args.get("query") or "").strip() if not query: return ToolResult(status="error", tool=self.name, message="query vide") try: num = max(1, min(int(args.get("num_results") or 5), 10)) except (TypeError, ValueError): num = 5 try: results, provider = await search_web(query, num) except Exception as exc: # noqa: BLE001 return ToolResult(status="error", tool=self.name, message=f"Recherche web indisponible: {exc}") if not results: hint = "" if not available_providers().get(provider, True): hint = " — configurez EXA_API_KEY dans les réglages FlowDeck" return ToolResult(status="error", tool=self.name, message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}") return ToolResult( status="success", tool=self.name, target_type="web_search", data={"query": query, "provider": provider, "results": results, "count": len(results)}, message=f"{len(results)} résultat(s) via {provider}", ) class FetchUrl(Tool): name = "fetch_url" description = ( "Télécharge une page web et la convertit en Markdown (titres, listes, " "code, tableaux) pour pouvoir la lire et la citer. Utilise-la après " "web_search pour approfondir un résultat. Les URLs internes / privées " "sont refusées." ) parameters = { "type": "object", "properties": { "url": {"type": "string", "description": "URL http(s) de la page"}, "max_chars": {"type": "integer", "description": "longueur max du markdown renvoyé (défaut 20000)"}, }, "required": ["url"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.config import settings from app.services.http_client import shared_client from app.services.importers.html_notes import _html_to_markdown from app.services.importers.url_fetch import _validate_url from app.services.og_fetcher import parse_og raw = str(args.get("url") or "").strip() if not raw: return ToolResult(status="error", tool=self.name, message="url vide") # « example.com/page » → https://example.com/page, mais un schéma # explicite (file:, ftp:, …) est conservé pour être REJETÉ par le # garde-fou, au lieu d'être transformé en requête réseau hasardeuse. if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw): raw = "https://" + raw try: url = _validate_url(raw) except ValueError as exc: return ToolResult(status="error", tool=self.name, message=str(exc)) try: max_chars = max(500, min(int(args.get("max_chars") or settings.web_fetch_max_chars), 60000)) except (TypeError, ValueError): max_chars = int(settings.web_fetch_max_chars or 20000) current = url resp = None try: async with shared_client(timeout=15.0, follow_redirects=False, headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client: for _ in range(6): resp = await client.get(current) if resp.status_code in (301, 302, 303, 307, 308): loc = resp.headers.get("location") if not loc: raise ValueError("redirection sans cible") from urllib.parse import urljoin current = urljoin(current, loc) # re-validation : une URL publique peut rediriger vers 169.254.x current = _validate_url(current) continue resp.raise_for_status() break else: raise ValueError("trop de redirections") except ValueError as exc: return ToolResult(status="error", tool=self.name, message=str(exc)) except Exception as exc: # noqa: BLE001 return ToolResult(status="error", tool=self.name, message=f"Page injoignable: {exc}") if resp is None: return ToolResult(status="error", tool=self.name, message="Aucune réponse") ctype = (resp.headers.get("content-type") or "").lower() body = resp.text if "html" not in ctype and "xhtml" not in ctype: # JSON / texte brut : on le renvoie tel quel, c'est souvent une API. markdown = body meta: dict = {} else: meta = parse_og(body, current) markdown = _html_to_markdown(body) truncated = len(markdown) > max_chars if truncated: markdown = markdown[:max_chars] + "\n\n[contenu tronqué]" title = (meta.get("title") or "").strip() or current return ToolResult( status="success", tool=self.name, target_type="web_page", target_id=current, data={"url": current, "title": title[:200], "markdown": markdown, "truncated": truncated, "chars": len(markdown)}, message=f"Page lue : {title[:80]} ({len(markdown)} car.)", ) class SearchCode(Tool): name = "search_code" description = ( "Recherche GitHub : dépôts, code source ou issues (uniquement GitHub " "public, sans clé possible mais limitée à 10 requêtes/min). Sert à " "trouver une implémentation de référence, une lib ou l'historique d'un bug." ) parameters = { "type": "object", "properties": { "query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"}, "kind": {"type": "string", "description": "repositories | code | issues (défaut repositories)"}, "limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"}, }, "required": ["query"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.services.web_search import search_github query = str(args.get("query") or "").strip() if not query: return ToolResult(status="error", tool=self.name, message="query vide") kind = str(args.get("kind") or "repositories") try: limit = max(1, min(int(args.get("limit") or 5), 10)) except (TypeError, ValueError): limit = 5 try: results = await search_github(query, kind, limit) except Exception as exc: # noqa: BLE001 return ToolResult(status="error", tool=self.name, message=f"Recherche GitHub impossible: {exc}") return ToolResult( status="success", tool=self.name, target_type="github", target_id=f"{kind}:{query}", data={"query": query, "kind": kind, "results": results, "count": len(results)}, message=f"{len(results)} résultat(s) GitHub ({kind})", ) # ══════════════════════════ Registry ══════════════════════════ class ConnectorFetch(Tool): name = "connector_fetch" description = ( "Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), " "recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). " "`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; " "`query` sert au connecteur web. Les hôtes privés sont refusés." ) parameters = { "type": "object", "properties": { "connector": {"type": "string", "description": "id, nom ou kind (gitea / github / web)"}, "path": {"type": "string", "description": "chemin relatif (API natives)"}, "query": {"type": "string", "description": "terme de recherche (web)"}, }, "required": ["connector"], } async def execute(self, args, *, user_id=None) -> ToolResult: from app.services import connectors try: res = await connectors.connector_fetch( str(args.get("connector") or ""), str(args.get("path") or ""), str(args.get("query") or ""), user_id=user_id, ) except ValueError as exc: return ToolResult(status="error", tool=self.name, message=str(exc)) except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run logger.warning("connector_fetch failed: %s", exc) return ToolResult(status="error", tool=self.name, message=f"Connecteur indisponible: {exc}") if res.get("status") != "ok": return ToolResult(status="error", tool=self.name, message=(res.get("text") or "erreur")[:500]) text = res.get("text") or "" return ToolResult(status="success", tool=self.name, target_type="connector", message=text[:800], data={"text": text}) class McpTool(Tool): """Outil MCP dynamique — instance construite à la volée depuis le cache en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES.""" def __init__(self, entry: dict): self.name = entry["name"] self.description = entry.get("description") or "Outil MCP" self.parameters = entry.get("parameters") or {"type": "object", "properties": {}} self.entry = entry async def execute(self, args, *, user_id=None) -> ToolResult: from app.services import mcp_client try: res = await mcp_client.call_tool(self.entry["connector_id"], self.entry.get("original") or "", args) except ValueError as exc: return ToolResult(status="error", tool=self.name, message=str(exc)) except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run logger.warning("MCP tool failed (%s): %s", self.name, exc) return ToolResult(status="error", tool=self.name, message=f"MCP indisponible: {exc}") if res.get("status") != "ok": return ToolResult(status="error", tool=self.name, message=(res.get("text") or "erreur MCP")[:500]) text = res.get("text") or "" return ToolResult(status="success", tool=self.name, target_type="mcp", message=text[:800], data={"text": text}) TOOL_CLASSES = [ SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument, CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument, UpdatePage, WriteBlocks, AddRelation, CreateSubItem, AddDependency, ApplyTemplate, DeletePage, DeleteCollection, ReadGiteaIssues, SyncGitea, CreateGiteaIssue, DeleteDocument, WebSearch, FetchUrl, SearchCode, ConnectorFetch, ] class ToolRegistry: """Holds tool instances and exposes their LLM schemas + execution.""" def __init__(self): self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES} def _all(self) -> dict[str, Tool]: """Outils statiques + **outils MCP dynamiques** (cache en base). Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base, donc « Tester le connecteur » suffit à rendre un outil disponible. """ tools = dict(self.tools) try: from app.services import mcp_client from app.services import plugins as plugins_service for entry in mcp_client.cached_tools(): tools[entry["name"]] = McpTool(entry) # plugin « web-tools » OFF → outils web retirés du schéma ET de execute() for slug, names in plugins_service.PLUGIN_TOOLS.items(): if not plugins_service.is_enabled(slug): for name in names: tools.pop(name, None) except Exception: # noqa: BLE001 — la base ne doit jamais casser un run logger.exception("dynamic tools load failed") return tools def list(self, scope: dict | None = None) -> list[str]: """Tool names allowed by an agent scope (default: all).""" tools = self._all() allowed = (scope or {}).get("tools") if allowed is None: return list(tools.keys()) return [n for n in tools if n in allowed] def schema(self, scope: dict | None = None) -> list[dict]: """Function-calling schema for the LLM, filtered by scope.""" tools = self._all() return [ {"name": tools[n].name, "description": tools[n].description, "parameters": tools[n].parameters} for n in self.list(scope) ] async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult: impl = self._all().get(tool) if not impl: return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}") return await impl.execute(args, user_id=user_id)