feat(agent): v4.10.0 FlowDeck Agent - agent IA ReAct (SSE, 18 outils + rollback, permissions, skills, triggers, multi-LLM)
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
"""FlowDeck — Agent tool registry (v4.10.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). Every mutating tool
|
||||
returns an *undo snapshot* so AgentEngine can journal + roll back each action.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
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) des collections et pages FlowDeck."
|
||||
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()
|
||||
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],
|
||||
},
|
||||
message=f"{len(colls)} collection(s) et {len(pages)} page(s) trouvée(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]},
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ 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 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 = "Écrit du contenu (blocs éditeur) sur une page éditeur (`pages`)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": {"type": "integer"},
|
||||
"blocks": {"type": "array"},
|
||||
},
|
||||
"required": ["page_id", "blocks"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
blocks = args.get("blocks", [])
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
if not page:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Page éditeur #{pid} introuvable")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(blocks, ensure_ascii=False), pid),
|
||||
)
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=pid,
|
||||
data={"page_id": pid, "blocks": len(blocks)},
|
||||
undo={"action": "update", "table": "pages", "id": pid, "snapshot": page["content"]},
|
||||
)
|
||||
|
||||
|
||||
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 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")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (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.services.gitea_client import GiteaClient, get_user_gitea_client
|
||||
from app.auth.session import SessionManager
|
||||
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.services.collection_adapter import GiteaBoardCompat
|
||||
from app.services.gitea_client import GiteaClient
|
||||
from app.auth.session import SessionManager
|
||||
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.services.gitea_client import GiteaClient
|
||||
from app.auth.session import SessionManager
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, UpdatePage, WriteBlocks,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
]
|
||||
|
||||
|
||||
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 list(self, scope: dict | None = None) -> list[str]:
|
||||
"""Tool names allowed by an agent scope (default: all)."""
|
||||
allowed = (scope or {}).get("tools")
|
||||
if allowed is None:
|
||||
return list(self.tools.keys())
|
||||
return [n for n in self.tools if n in allowed]
|
||||
|
||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||
"""Function-calling schema for the LLM, filtered by scope."""
|
||||
return [
|
||||
{"name": self.tools[n].name,
|
||||
"description": self.tools[n].description,
|
||||
"parameters": self.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.tools.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)
|
||||
Reference in New Issue
Block a user