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,271 @@
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.10.0).
|
||||
|
||||
`objective → comprehension → context → reasoning ↔ action → result`.
|
||||
|
||||
The engine drives the LLM (which only emits tool intentions), gates each call
|
||||
through PermissionManager, executes it via ToolRegistry, journals every action
|
||||
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
|
||||
the UI can render reasoning + actions live.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.context_builder import ContextBuilder
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ITERATIONS = 12
|
||||
|
||||
|
||||
class AgentEngine:
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None,
|
||||
llm: LLMClient | None = None):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
self.llm = llm or LLMClient()
|
||||
self.tools = ToolRegistry()
|
||||
self.ctx = ContextBuilder(user_id, workspace_id)
|
||||
self.perms = PermissionManager(user_id)
|
||||
self._tokens = 0
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _load_agent(self, conversation_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT a.* FROM agents a JOIN agent_conversations c ON c.agent_id=a.id WHERE c.id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
row = {"id": None, "name": "FlowDeck Agent", "icon": "🤖", "agent_type": "personal",
|
||||
"system_instructions": "", "model": settings.llm_model,
|
||||
"scope_json": "{}", "approval_mode": "auto"}
|
||||
return dict(row)
|
||||
|
||||
def _build_system_prompt(self, agent: dict, skill: dict | None = None) -> str:
|
||||
lines = [
|
||||
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
|
||||
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
|
||||
"Appelle UN ou PLUSIEURS outils pour atteindre l'objectif, puis conclus avec une réponse finale.",
|
||||
"N'invente jamais d'IDs : utilise ceux fournis dans le contexte.",
|
||||
f"Workspace courant : {self.workspace_id}.",
|
||||
]
|
||||
if agent.get("system_instructions"):
|
||||
lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}")
|
||||
if skill:
|
||||
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Main run (async generator of SSE events) ──
|
||||
|
||||
async def run(self, conversation_id: int, objective: str, *, model: str | None = None,
|
||||
mentions: list[str] | None = None, files: list[dict] | None = None,
|
||||
skill_id: int | None = None):
|
||||
agent = self._load_agent(conversation_id)
|
||||
scope = json.loads(agent.get("scope_json") or "{}")
|
||||
approval_mode = agent.get("approval_mode") or "auto"
|
||||
model = model or agent.get("model") or settings.llm_model
|
||||
|
||||
skill = self._load_skill(skill_id, scope) if skill_id else None
|
||||
system = self._build_system_prompt(agent, skill)
|
||||
context = self.ctx.build(mentions=mentions, files=files)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
|
||||
]
|
||||
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
|
||||
tool_schema = self.tools.schema(scope)
|
||||
final_text = None
|
||||
|
||||
try:
|
||||
for step in range(settings.agent_max_iterations or MAX_ITERATIONS):
|
||||
if self._tokens >= settings.agent_max_tokens_budget:
|
||||
yield self._event("error", {"message": "Budget de tokens dépassé"})
|
||||
break
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
self.llm.complete(messages, model=model, tools=tool_schema, stream=True),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
self._tokens += response.usage.get("total_tokens", 0) or 0
|
||||
|
||||
if response.text and response.text.strip():
|
||||
yield self._event("reasoning", {"content": response.text})
|
||||
messages.append({"role": "assistant", "content": response.text})
|
||||
|
||||
if not response.tool_calls:
|
||||
final_text = response.text or self._no_tool_message(response)
|
||||
yield self._event("final", {"content": final_text})
|
||||
break
|
||||
|
||||
for call in response.tool_calls:
|
||||
tool, args = call["name"], call.get("arguments") or {}
|
||||
try:
|
||||
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
|
||||
except Exception as exc: # permission / approval guard
|
||||
detail = self._exc_detail(exc)
|
||||
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
|
||||
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
|
||||
continue
|
||||
|
||||
result = await self.tools.execute(tool, args, user_id=self.user_id)
|
||||
|
||||
if result.status == "success":
|
||||
yield self._event("action", {
|
||||
"tool": tool, "status": result.status,
|
||||
"target_type": result.target_type, "target_id": result.target_id,
|
||||
"message": result.message,
|
||||
})
|
||||
self._log_action(conversation_id, tool, args, result.data, "success",
|
||||
target_type=result.target_type, target_id=result.target_id,
|
||||
undo=result.undo)
|
||||
messages.append({
|
||||
"role": "tool", "name": tool,
|
||||
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
|
||||
})
|
||||
else:
|
||||
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
|
||||
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
|
||||
messages.append({
|
||||
"role": "tool", "name": tool,
|
||||
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
|
||||
})
|
||||
|
||||
if final_text is None:
|
||||
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
|
||||
yield self._event("final", {"content": final_text})
|
||||
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=model, tokens=self._tokens)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
yield self._event("error", {"message": f"Erreur interne: {exc}"})
|
||||
finally:
|
||||
self._update_conversation(conversation_id, status="idle")
|
||||
|
||||
# ── Skills ──
|
||||
|
||||
def _load_skill(self, skill_id: int, scope: dict | None) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
skill = dict(row)
|
||||
allowed = json.loads(skill.get("allowed_tools_json") or "[]")
|
||||
if allowed:
|
||||
skill["prompt_template"] = (skill.get("prompt_template") or "") + \
|
||||
"\nOutils autorisés: " + ", ".join(allowed)
|
||||
return skill
|
||||
|
||||
# ── Audit & persistence ──
|
||||
|
||||
def _log_action(self, conversation_id, tool, args, result, status,
|
||||
*, target_type="", target_id=None, undo=None, detail=""):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_actions
|
||||
(conversation_id, tool_name, target_type, target_id, payload_json,
|
||||
result_json, status, undo_snapshot_json, executed_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(conversation_id, tool, target_type,
|
||||
str(target_id) if target_id is not None else None,
|
||||
json.dumps(args, ensure_ascii=False),
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
status,
|
||||
json.dumps(undo or {}, ensure_ascii=False),
|
||||
self.user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _persist_message(self, conversation_id, role, content, *, model="", tokens=0):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (conversation_id, role, content, model, tokens_used) VALUES (?,?,?,?,?)",
|
||||
(conversation_id, role, content, model, tokens),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _update_conversation(self, conversation_id, *, status=None, title=None):
|
||||
with get_conn() as conn:
|
||||
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
|
||||
if status:
|
||||
sets.append("status=?")
|
||||
params.append(status)
|
||||
if title:
|
||||
sets.append("title=?")
|
||||
params.append(title)
|
||||
params.append(conversation_id)
|
||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
|
||||
# ── Misc ──
|
||||
|
||||
@staticmethod
|
||||
def _event(etype: str, data: dict) -> dict:
|
||||
return {"type": etype, **data}
|
||||
|
||||
@staticmethod
|
||||
def _no_tool_message(response) -> str:
|
||||
return "Je n'ai pas d'action à proposer pour cet objectif. Posez-moi une question plus précise ou demandez-moi de créer un élément."
|
||||
|
||||
@staticmethod
|
||||
def _exc_detail(exc: Exception) -> str:
|
||||
detail = getattr(exc, "detail", None)
|
||||
return detail if isinstance(detail, str) else str(exc)
|
||||
|
||||
|
||||
def undo_action(action_id: int) -> bool:
|
||||
"""Reverse a single agent action using its stored undo snapshot.
|
||||
|
||||
Returns True on success. Marks the action row `reverted`.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
action = conn.execute("SELECT * FROM agent_actions WHERE id=?", (action_id,)).fetchone()
|
||||
if not action:
|
||||
raise ValueError(f"Action #{action_id} introuvable")
|
||||
undo = json.loads(action["undo_snapshot_json"] or "{}")
|
||||
op, table, rid = undo.get("action"), undo.get("table"), undo.get("id")
|
||||
if not op or not table or rid is None:
|
||||
raise ValueError(f"Action #{action_id} n'a pas de snapshot annulable")
|
||||
|
||||
if op == "delete":
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
|
||||
elif op == "insert":
|
||||
snapshot = undo.get("snapshot")
|
||||
if not snapshot:
|
||||
raise ValueError("Snapshot manquant pour insert")
|
||||
cols = ", ".join(snapshot.keys())
|
||||
ph = ", ".join("?" for _ in snapshot)
|
||||
conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({ph})", list(snapshot.values()))
|
||||
elif op == "update":
|
||||
snapshot = undo.get("snapshot")
|
||||
if table == "collection_pages":
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
|
||||
)
|
||||
elif table == "pages":
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot, rid))
|
||||
else:
|
||||
raise ValueError(f"Table non gérée pour rollback: {table}")
|
||||
else:
|
||||
raise ValueError(f"Opération de rollback inconnue: {op}")
|
||||
|
||||
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -0,0 +1,105 @@
|
||||
"""FlowDeck — Agent context builder (v4.10.0).
|
||||
|
||||
Collects a compact, permission-filtered snapshot of the active workspace so the
|
||||
LLM can reason about real entities (collections, pages, Gitea issues) without
|
||||
touching the database directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the textual context that accompanies each agent run."""
|
||||
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
|
||||
def build(self, *, mentions: list[str] | None = None,
|
||||
files: list[dict] | None = None,
|
||||
include_collections: bool = True) -> str:
|
||||
"""Return a compact Markdown-ish snapshot of the workspace context."""
|
||||
sections: list[str] = []
|
||||
|
||||
if include_collections:
|
||||
sections.append(self._collections_context())
|
||||
sections.append(self._pages_context())
|
||||
if mentions:
|
||||
sections.append(self._mentions_context(mentions))
|
||||
if files:
|
||||
sections.append(self._files_context(files))
|
||||
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
# ── Internals ──
|
||||
|
||||
def _collections_context(self) -> str:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Collections\n(no collections yet)"
|
||||
lines = ["## Collections"]
|
||||
lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows))
|
||||
for r in rows:
|
||||
props = json.loads(r["schema_json"]) if r["schema_json"] else []
|
||||
schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none"
|
||||
lock = " [LOCKED]" if r["is_locked"] else ""
|
||||
lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _pages_context(self, limit: int = 40) -> str:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Pages\n(no pages yet)"
|
||||
lines = ["## Recent pages"]
|
||||
for r in rows:
|
||||
props = json.loads(r["property_values_json"]) if r["property_values_json"] else {}
|
||||
summary = ", ".join(str(v) for v in props.values() if v) if props else ""
|
||||
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @collection:x / @page:y / @repo:o/r mentions to entity snapshots."""
|
||||
lines = ["## Mentioned context"]
|
||||
for m in mentions:
|
||||
if m.startswith("collection:"):
|
||||
cid = m.split(":", 1)[1]
|
||||
lines.append(self._single_collection(cid))
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
lines.append("- @ws: full workspace context included above")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
return f"- collection #{cid}: not found"
|
||||
return f"- collection #{row['id']} {row['icon']} **{row['name']}**"
|
||||
|
||||
def _single_page(self, pid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
if not row:
|
||||
return f"- page #{pid}: not found"
|
||||
props = json.loads(row["property_values_json"]) if row["property_values_json"] else {}
|
||||
return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}"
|
||||
|
||||
def _files_context(self, files: list[dict]) -> str:
|
||||
return "## Attached files\n" + "\n".join(
|
||||
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""FlowDeck — LLM client abstraction (v4.10.0).
|
||||
|
||||
Abstraction over multiple LLM providers so the agent never talks to the DB
|
||||
directly — it emits *tool intentions* (function calls) that AgentEngine turns
|
||||
into guarded internal actions.
|
||||
|
||||
Supported providers (OpenAI-compatible chat-completions JSON response):
|
||||
openai, anthropic*, google*, deepseek, qwencloud, nvidia, openrouter, ollama.
|
||||
(* routed through an OpenAI-compatible gateway / any configured api_base)
|
||||
|
||||
When no API key is configured (or provider == "offline") the client falls back
|
||||
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
|
||||
functional — and fully testable — with zero external calls, which is what the
|
||||
local deployment and the test-suite rely on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Provider → default model + base URL when llm_model/api_base are empty.
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta", "gemini-2.0-pro"),
|
||||
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
||||
"qwencloud": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-70b-instruct"),
|
||||
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
|
||||
"ollama": ("http://localhost:11434/v1", "llama3.1"),
|
||||
"offline": (None, None),
|
||||
}
|
||||
|
||||
# Llama-style / ChatML tool markers used by the mock planner.
|
||||
_CREATE_PATTERNS = [
|
||||
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
|
||||
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
||||
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
||||
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("create_page", {"title": m.group(1).strip()})),
|
||||
]
|
||||
_SEARCH_PATTERNS = [
|
||||
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
|
||||
]
|
||||
|
||||
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
|
||||
# "créer la collection X", "create collection X", etc.)
|
||||
_COLLECTION_LINE = re.compile(
|
||||
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Normalized completion: either a final text or one or more tool calls."""
|
||||
text: str = ""
|
||||
tool_calls: list[dict] = field(default_factory=list)
|
||||
model: str = ""
|
||||
usage: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Multi-provider chat client with tool-calling support and offline mock."""
|
||||
|
||||
def __init__(self, provider: str | None = None, api_key: str | None = None,
|
||||
api_base: str | None = None):
|
||||
self.provider = (provider or settings.llm_provider or "offline").lower()
|
||||
self.api_key = api_key if api_key is not None else settings.llm_api_key
|
||||
self.api_base = api_base if api_base is not None else settings.llm_api_base
|
||||
base, model = PROVIDERS.get(self.provider, (None, None))
|
||||
self.api_base = self.api_base or base
|
||||
self.default_model = settings.llm_model or model or "gpt-4o"
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
async def complete(self, messages: list[dict], *, model: str | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
stream: bool = False) -> LLMResponse:
|
||||
"""Send a chat completion. Returns text and/or tool_calls."""
|
||||
model = model or self.default_model
|
||||
|
||||
if self.provider == "offline" or not self._has_credentials():
|
||||
return await self._mock_complete(messages, model, tools)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._http_complete(messages, model, tools),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — degrade gracefully to mock
|
||||
logger.warning("LLM provider '%s' failed (%s); falling back to offline", self.provider, exc)
|
||||
return await self._mock_complete(messages, model, tools)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""True when a real provider is configured."""
|
||||
return self.provider != "offline" and self._has_credentials()
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _has_credentials(self) -> bool:
|
||||
if self.provider == "ollama":
|
||||
return True # local, no key required
|
||||
return bool(self.api_key)
|
||||
|
||||
def _endpoint(self) -> str:
|
||||
return f"{self.api_base.rstrip('/')}/chat/completions"
|
||||
|
||||
async def _http_complete(self, messages, model, tools) -> LLMResponse:
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = [{"type": "function", "function": t} for t in tools]
|
||||
payload["tool_choice"] = "auto"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
resp = await client.post(self._endpoint(), json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
choice = data["choices"][0]["message"]
|
||||
text = choice.get("content") or ""
|
||||
tool_calls = []
|
||||
for tc in choice.get("tool_calls") or []:
|
||||
try:
|
||||
args = json.loads(tc["function"].get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
tool_calls.append({"name": tc["function"]["name"], "arguments": args})
|
||||
|
||||
return LLMResponse(
|
||||
text=text,
|
||||
tool_calls=tool_calls,
|
||||
model=model,
|
||||
usage=data.get("usage", {}),
|
||||
)
|
||||
|
||||
# ── Offline mock planner (deterministic, no network) ──
|
||||
|
||||
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
|
||||
user_content = self._last_user_content(messages)
|
||||
sys_content = self._system_content(messages)
|
||||
|
||||
# Once tool results are already in the conversation, we have acted:
|
||||
# stop issuing new tool calls and conclude.
|
||||
if any(m.get("role") == "tool" for m in messages):
|
||||
return LLMResponse(
|
||||
text="Objectif traité — actions enregistrées dans le journal d'audit.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Skill-driven: if the objective names a known skill, mirror its template.
|
||||
skill_hint = self._extract_skill_hint(user_content)
|
||||
|
||||
if skill_hint == "sprint":
|
||||
return LLMResponse(
|
||||
tool_calls=[
|
||||
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
|
||||
{"name": "create_collection", "arguments": {"name": "Sprint"}},
|
||||
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
|
||||
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
|
||||
],
|
||||
text="Plan: analyze open issues, then build a sprint board.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Exact keyword → tool intent resolution.
|
||||
for regex, builder in _CREATE_PATTERNS:
|
||||
m = regex.search(user_content)
|
||||
if m:
|
||||
return LLMResponse(
|
||||
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
|
||||
text=f"Plan: running {builder(m)[0]}.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
for regex, builder in _SEARCH_PATTERNS:
|
||||
m = regex.search(user_content)
|
||||
if m:
|
||||
return LLMResponse(
|
||||
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
|
||||
text=f"Plan: searching '{m.group(1)}'.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Loose "collection <X>" detection → treat as a create intent.
|
||||
low = user_content.lower()
|
||||
if "collection" in low:
|
||||
m = _COLLECTION_LINE.search(user_content)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_collection",
|
||||
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
|
||||
text=f"Plan: create collection '{name}'.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Plain conversational objective → final answer (no tool).
|
||||
return LLMResponse(
|
||||
text=self._summarize(user_content),
|
||||
model=model,
|
||||
)
|
||||
|
||||
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
|
||||
"""Inject a collection id from the context when the planner left it as 0."""
|
||||
args = dict(args)
|
||||
if args.get("collection_id") == 0:
|
||||
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
|
||||
if match:
|
||||
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
|
||||
if ids:
|
||||
args["collection_id"] = ids[0]
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _system_content(messages) -> str:
|
||||
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
|
||||
@staticmethod
|
||||
def _last_user_content(messages) -> str:
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user":
|
||||
c = m.get("content", "")
|
||||
if isinstance(c, list):
|
||||
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
||||
return str(c)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_skill_hint(content: str) -> str | None:
|
||||
low = content.lower()
|
||||
if "sprint" in low or "préparation de sprint" in low:
|
||||
return "sprint"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _summarize(content: str) -> str:
|
||||
"""Produce a terse final summary from a conversational objective."""
|
||||
content = content.split("\n# Contexte")[0]
|
||||
return (content[:600] + "…" if len(content) > 600 else content)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""FlowDeck — Agent permission guard (v4.10.0).
|
||||
|
||||
The agent always acts with *at most* the permissions of the invoking user
|
||||
(Notion Agent principle). This manager resolves the user's role in the active
|
||||
workspace and gates tool execution before any write reaches the database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Workspace roles, from least to most privileged.
|
||||
READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
|
||||
WRITE_ROLES = {"editor", "admin", "owner"}
|
||||
DESTRUCTIVE_ROLES = {"admin", "owner"}
|
||||
|
||||
# Tools that mutate state and therefore require at least an editor role.
|
||||
WRITE_TOOLS = {
|
||||
"create_collection", "create_view", "create_page", "update_page",
|
||||
"write_blocks", "add_property", "add_relation", "create_sub_item",
|
||||
"add_dependency", "sync_gitea", "create_gitea_issue", "apply_template",
|
||||
}
|
||||
|
||||
# Tools that delete / are destructive → admin/owner (or confirm mode).
|
||||
DESTRUCTIVE_TOOLS = {
|
||||
"delete_page", "delete_collection", "delete_property", "delete_view",
|
||||
}
|
||||
|
||||
|
||||
class PermissionManager:
|
||||
"""Resolves workspace role and gates agent tool calls."""
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
self.user_id = user_id
|
||||
|
||||
# ── Role resolution ──
|
||||
|
||||
def role_in_workspace(self, workspace_id: int | None) -> str:
|
||||
"""Return the user's role for a workspace (owner > member role)."""
|
||||
if workspace_id is None:
|
||||
# No workspace → fall back to the most permissive own-content model.
|
||||
return "owner"
|
||||
with get_conn() as conn:
|
||||
member = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
if member:
|
||||
return member["role"] or "editor"
|
||||
owner = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
return "owner" if owner else "viewer"
|
||||
|
||||
def can_read(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in READ_ROLES
|
||||
|
||||
def can_write(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in WRITE_ROLES
|
||||
|
||||
def can_destructive(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in DESTRUCTIVE_ROLES
|
||||
|
||||
# ── Gate for the engine ──
|
||||
|
||||
def assert_can(self, tool: str, args: dict, workspace_id: int | None,
|
||||
approval_mode: str = "auto") -> None:
|
||||
"""Raise HTTPException if the tool call exceeds the user's permissions.
|
||||
|
||||
- read tools: any authenticated user in the workspace (viewer+).
|
||||
- write tools: editor+.
|
||||
- destructive tools: admin/owner, or requires confirm approval mode.
|
||||
"""
|
||||
role = self.role_in_workspace(workspace_id)
|
||||
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
|
||||
)
|
||||
if tool in DESTRUCTIVE_TOOLS:
|
||||
if role not in DESTRUCTIVE_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Agent tool '{tool}' is destructive and requires admin/owner "
|
||||
f"(user is '{role}')",
|
||||
)
|
||||
if approval_mode != "confirm":
|
||||
raise HTTPException(
|
||||
status_code=428, # Precondition Required
|
||||
detail=f"Destructive tool '{tool}' requires approval (confirm mode)",
|
||||
)
|
||||
# A viewer can always read; editor can read+write.
|
||||
if role not in READ_ROLES:
|
||||
raise HTTPException(status_code=403, detail="User has no access to this workspace")
|
||||
@@ -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