From e752e4658366bb67d7018bf35e11f162e05f5905 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 5 Sep 2026 09:58:04 -0400 Subject: [PATCH] feat(agent): v4.10.0 FlowDeck Agent - agent IA ReAct (SSE, 18 outils + rollback, permissions, skills, triggers, multi-LLM) --- CHANGELOG.md | 37 ++ ROADMAP.md | 107 +++-- VERSION | 2 +- app/config.py | 12 + app/db.py | 88 ++++ app/main.py | 22 +- app/middleware/csrf.py | 2 +- app/routers/agent.py | 424 +++++++++++++++++++ app/services/agent_engine.py | 271 ++++++++++++ app/services/context_builder.py | 105 +++++ app/services/llm_client.py | 257 ++++++++++++ app/services/permission_manager.py | 100 +++++ app/services/tool_registry.py | 649 +++++++++++++++++++++++++++++ app/templates/agent_message.html | 17 + app/templates/agent_panel.html | 224 ++++++++++ app/templates/base.html | 28 +- tests/test_agent.py | 329 +++++++++++++++ 17 files changed, 2638 insertions(+), 36 deletions(-) create mode 100644 app/routers/agent.py create mode 100644 app/services/agent_engine.py create mode 100644 app/services/context_builder.py create mode 100644 app/services/llm_client.py create mode 100644 app/services/permission_manager.py create mode 100644 app/services/tool_registry.py create mode 100644 app/templates/agent_message.html create mode 100644 app/templates/agent_panel.html create mode 100644 tests/test_agent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 228b6ce..5ba2944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog — FlowDeck +## v4.10.0 (2026-09-05) — FlowDeck Agent : agent IA natif conversationnel + +> Implémentation complète du pilier « Agent IA » : un agent ReAct intégré qui **agit** sur le +> workspace (pas seulement répond) — panneau de chat, boucle raisonnement→action, streaming SSE, +> 18 outils avec snapshots rollback, permissions par rôle, contexte automatique, custom agents, +> déclencheurs planifiés, skills réutilisables et multi-LLM (offline mock + 8 providers). + +### Added +- **Interface conversationnelle** : panneau agent dans le sidebar (🤖 Agents) piloté par Alpine.js + + streaming SSE ; FAB ouvrant le chat ; historique de conversations. +- **Boucle ReAct** (`app/services/agent_engine.py`) : raisonnement → action → observation, max 12 + itérations, budget de tokens, timeout, journal d'audit + fonction `undo_action()` (rollback). +- **Streaming SSE** : endpoint `/api/agent/conversations/{id}/run` qui émet `reasoning`, `action` + et `final` en temps réel. +- **18 outils** (`app/services/tool_registry.py`) : `search_workspace`, `read_collection`, + `read_page`, `create_collection`, `create_view`, `add_property`, `create_page`, `update_page`, + `write_blocks`, `add_relation`, `create_sub_item`, `add_dependency`, `apply_template`, + `delete_page`, `delete_collection`, `read_gitea_issues`, `sync_gitea`, `create_gitea_issue` — + chacun avec `ToolResult` + snapshot d'undo réversible. +- **Permissions** (`app/services/permission_manager.py`) : même ACL que l'utilisateur par rôle + workspace (viewer/editor/admin/owner) + gating des outils. +- **Contexte automatique** (`app/services/context_builder.py`) : snapshot du workspace actif, + collections, pages, mentions `@`, fichiers uploadés injectés dans le prompt système. +- **Multi-LLM** (`app/services/llm_client.py`) : OpenAI, Anthropic, Google, Ollama, DeepSeek, + QwenCloud, NVIDIA, OpenRouter + **offline mock** (planner déterministe, aucune clé requise). +- **Custom agents, skills, déclencheurs** : CRUD agents + conversations, `agent_*` tables + (`agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`, + `agent_triggers`), skills (`/api/agent/skills`, apply), scheduler de fond pour triggers planifiés. + +### Infra +- `VERSION` → 4.10.0 ; config LLM (`LLM_PROVIDER/MODEL/API_KEY/API_BASE`, `AGENT_MAX_*`) ; + exclusions CSRF `/api/agent` ; routeur agent + tâche scheduler dans le lifespan de `app/main.py`. + +### Tests +- **226 tests verts** (+18 v4.10.0 : schéma DB, mock LLM, permissions, outils+undo, engine ReAct + + audit + rollback, CRUD agents/conversations, streaming SSE, skills, tools, providers). + ## v4.9.0 (2026-09-04) — Collaboration : commentaires inline, mentions @, notifications > Implémentation complète du pilier « Collaboration » — basée sur la revue des docs Notion diff --git a/ROADMAP.md b/ROADMAP.md index f9f8332..91472bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -323,11 +323,12 @@ Détails livrés : - [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions) - [x] **208 tests passent** (+9 v4.9.0) -### v4.10.0 — FlowDeck Agent (Agent IA natif) ⬜ (0 % implémenté) +### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅ -> ⚠️ **État actuel** : documentation de design complète (`docs/Flowdeck_Agent_integration.md`), -> mais **aucun code** : pas de `routers/agent.py`, `services/agent_engine.py`, `llm_client.py`, -> `tool_registry.py`, `context_builder.py`, ni aucune table `agent_*`. +> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE, +> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents, +> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés. +> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md). **Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026). @@ -348,16 +349,16 @@ Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agen | Aucune action DB | Modifie le workspace via API FastAPI existantes | #### Fonctionnalités clés -- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat -- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours) -- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions -- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync -- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback -- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés -- [ ] **Custom agents** — instructions, modèle, outils, scope par agent -- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace -- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo") -- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter +- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat +- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours) +- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions +- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync +- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback +- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés +- [x] **Custom agents** — instructions, modèle, outils, scope par agent +- [x] **Déclencheurs** — planifiés, webhooks, événements workspace +- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo") +- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter #### Architecture (nouveaux composants) ``` @@ -511,6 +512,57 @@ app/ --- +## v5.10.0 — Éditeur : interactions de bloc ⬜ (non commencé) +> **Objectif** : parité de manipulation des blocs avec Notion. Actuellement les blocs sont +> éditables mais *statiques* : impossible de les déplacer, dupliquer ou annuler une action. + +- [ ] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue), support multi-sélection de blocs (Shift+clic → déplacement groupé) +- [ ] **Menu contextuel de bloc** — clic sur ⋮⋮ : Turn into (changer le type), Duplicate, Copy link to block, Move to, Delete, couleur de texte/fond +- [ ] **Undo / Redo** — Ctrl+Z / Ctrl+Shift+Z ou Ctrl+Y sur l'état des blocs (pile d'opérations inversibles par page, conservée en mémoire du tab) +- [ ] **Duplicate block** — Ctrl+D sur le bloc courant (copie insérée juste en dessous) +- [ ] **Slash command étendue** — la commande `/` propose aussi : Turn into, Duplicate, et toutes les actions du menu bloc +- [ ] **En-têtes de tableau (bloc table v4.8.0)** — toggles « First row as header » / « First column as header » dans la config du bloc table, rendu `` correspondant en preview et exports + +## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé) +> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0 +> (section « Lié depuis ») par la création des liens depuis l'éditeur. + +- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement) +- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline +- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date +- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur) + +## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé) +> **Objectif** : démarrage rapide productif et protection des pages stabilisées. +> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. + +- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur +- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page +- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar +- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion) +- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono + +## v5.13.0 — Collaboration temps réel ⬜ (non commencé) +> **Objectif** : avancer depuis v6.0.0 le chantier n°1 de la parité Notion en équipe. +> Sans realtime, FlowDeck reste un outil mono-utilisateur partagé. + +- [ ] **WebSocket gateway** — endpoint `/ws/pages/{id}`, auth via session, rooms par page +- [ ] **Présence** — avatars des utilisateurs connectés sur la page (topbar), couleur par utilisateur +- [ ] **Curseurs live** — position des curseurs/sélections des autres éditeurs, label avec nom +- [ ] **Merge de modifications** — diffusion des opérations de blocs (insert/update/delete/move) avec last-write-wins par bloc + version de page pour détecter les conflits +- [ ] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s + +## v5.14.0 — Synced blocks ⬜ (non commencé) +> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans +> plusieurs pages, édité une fois). + +- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced` +- [ ] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload) +- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante +- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync » + +--- + ## v6.0.0 — Pro (futur) - [ ] **PWA** — Progressive Web App, offline support @@ -518,8 +570,8 @@ app/ - [ ] **Granular permissions** — page-level, property-level access control - [ ] **Web Clipper** — extension navigateur - [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist) -- [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs -- [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages +- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle +- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues --- @@ -535,11 +587,10 @@ app/ ## 🎯 Ordre de priorité recommandé (état 2026-09) 1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste) -2. **v4.9.0 → Collaboration ✅** (comments + mentions + notifications) — livré 2026-09-04 -3. **v5.0.0 → Command palette + FTS5** — le raccourci Ctrl+K est déjà câblé, petit effort / gros impact -4. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur -5. **v4.10.0 → Agent IA** — gros chantier, démarrer Phase 1 (engine + 3 outils) une fois 1–4 livrés - +2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact +3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur +4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks) +5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré --- ## Résumé des phases @@ -551,11 +602,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard + UI Notion + Tags + Admin + Sharing COMPLETED + GitHub OAuth + Library -v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ✅ v4.8.0 ✅ v4.9.0 ✅ v4.10 ⬜ v5.0–5.9 ⬜ v6.0 ⬜ -Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Bloc Table Collaboration Agent IA Palette → Pro + Agent -& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML simple (comments@) (futur) Automations (futur) - (futur) Embeds + - Import + DB - + Calendrier +v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.9 ⬜ v5.10–5.12 ⬜ v5.13–5.14 ⬜ v6.0 ⬜ +Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent +& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks + Collaboration Embeds, Import, undo/redo) + + DB avancée, Wiki-links, + Calendrier, AI Templates & lock -*Dernière mise à jour: 2026-09-04 — Audit complet v4.9–v5.3 + analyse Notion clone : v5.5.0 → v5.9.0 ajoutées (Embeds, Import, DB avancée, Calendrier & Rappels, AI Writing)* +*Dernière mise à jour: 2026-09-05 — v4.10.0 Agent IA livré (18 tests) ; audit précédent : v5.10.0 → v5.14.0 ajoutées (interactions de bloc, wiki-links & mentions, templates & page lock, realtime, synced blocks)* diff --git a/VERSION b/VERSION index b617d99..2da4316 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.9.0 \ No newline at end of file +4.10.0 diff --git a/app/config.py b/app/config.py index 075eda0..bb4d2e4 100644 --- a/app/config.py +++ b/app/config.py @@ -59,6 +59,18 @@ class Settings(BaseSettings): smtp_use_tls: bool = True app_base_url: str = "http://localhost:8080" + # FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode + # (deterministic rule-based planner so the agent works without any API key). + agent_enabled: bool = True + llm_provider: str = "offline" # openai | anthropic | google | ollama | + # deepseek | qwencloud | nvidia | openrouter | offline + llm_model: str = "gpt-4o" + llm_api_key: str = "" + llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...) + agent_max_iterations: int = 12 + agent_max_tokens_budget: int = 500000 + agent_run_timeout_seconds: int = 300 + @property def db_path(self) -> Path: if self.database_url == "sqlite:///:memory:": diff --git a/app/db.py b/app/db.py index fdf1eae..3fedd19 100644 --- a/app/db.py +++ b/app/db.py @@ -640,6 +640,94 @@ def init_db(): pass conn.commit() + # ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════ + conn.execute(""" + CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER REFERENCES workspaces(id), + name TEXT NOT NULL DEFAULT 'FlowDeck Agent', + icon TEXT DEFAULT '🤖', + agent_type TEXT NOT NULL DEFAULT 'personal', + description TEXT DEFAULT '', + system_instructions TEXT DEFAULT '', + model TEXT DEFAULT 'gpt-4o', + scope_json TEXT NOT NULL DEFAULT '{}', + trigger_json TEXT NOT NULL DEFAULT '{}', + approval_mode TEXT NOT NULL DEFAULT 'auto', + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES users(id), + UNIQUE(workspace_id, name) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + title TEXT DEFAULT 'New conversation', + status TEXT NOT NULL DEFAULT 'idle', + context_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + tool_calls_json TEXT DEFAULT '[]', + model TEXT, + tokens_used INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE, + tool_name TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'success', + undo_snapshot_json TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + executed_by INTEGER REFERENCES users(id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_skills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER REFERENCES workspaces(id), + name TEXT NOT NULL, + description TEXT DEFAULT '', + prompt_template TEXT NOT NULL, + allowed_tools_json TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES users(id), + UNIQUE(workspace_id, name) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL DEFAULT 'manual', + config_json TEXT NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT 1, + last_fired_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)") + conn.commit() + @contextmanager def get_conn(): diff --git a/app/main.py b/app/main.py index d956d3b..730237e 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import asyncio from contextlib import asynccontextmanager from fastapi import FastAPI, Request @@ -13,7 +14,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent from app.routers.notifications import router as notifications_router from app.routers.collaboration import router as collaboration_router from app.routers.gitea import router as gitea_router @@ -41,13 +42,25 @@ async def lifespan(_app: FastAPI): (admin_hash,) ) conn.commit() - logger.info("FlowDeck v4.9.0 started on port %d", settings.app_port) - yield + + # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ── + from app.routers.agent import agent_scheduler + scheduler_task = asyncio.create_task(agent_scheduler()) + + logger.info("FlowDeck v4.10.0 started on port %d", settings.app_port) + try: + yield + finally: + scheduler_task.cancel() + try: + await scheduler_task + except asyncio.CancelledError: + pass app = FastAPI( title="FlowDeck", - version="4.9.0", + version="4.10.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -78,6 +91,7 @@ app.include_router(sidebar_config.router) app.include_router(export.router) app.include_router(notifications_router) app.include_router(collaboration_router) +app.include_router(agent.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 0f73b53..78910ca 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/routers/agent.py b/app/routers/agent.py new file mode 100644 index 0000000..084e00f --- /dev/null +++ b/app/routers/agent.py @@ -0,0 +1,424 @@ +"""FlowDeck — Agent router (v4.10.0). /api/agent/* + +Agents, conversations, SSE run, audit & rollback, skills, triggers, tools. +""" +from __future__ import annotations + +import asyncio +import json +import logging + +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import StreamingResponse + +from app.db import get_conn +from app.config import settings +from app.auth.session import get_current_user +from app.services.agent_engine import AgentEngine, undo_action +from app.services.llm_client import LLMClient +from app.services.tool_registry import ToolRegistry +from app.services.permission_manager import PermissionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["agent"], prefix="/api/agent") + + +async def agent_scheduler(interval_seconds: int = 60): + """Background loop that fires scheduled custom agents (trigger_type=schedule). + + Runs inside the FastAPI lifespan. A trigger's config_json may use a cron-like + string ("* * * * *") — for simplicity we fire at most once per poll interval + when the trigger is active and not currently running. + """ + from datetime import datetime, timedelta + logger.info("FlowDeck Agent scheduler started") + while True: + try: + await asyncio.sleep(interval_seconds) + with get_conn() as conn: + triggers = conn.execute( + "SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1" + ).fetchall() + now = datetime.utcnow() + for trig in triggers: + last = trig["last_fired_at"] + if last: + try: + last_dt = datetime.fromisoformat(last) + except ValueError: + last_dt = None + if last_dt and now - last_dt < timedelta(seconds=interval_seconds): + continue + # fire: run the agent's system_instructions through the engine + agent_row = conn.execute("SELECT * FROM agents WHERE id=?", (trig["agent_id"],)).fetchone() + if not agent_row: + continue + user_id = agent_row["created_by"] + cur = conn.execute( + "INSERT INTO agent_conversations (agent_id, user_id, title, context_json) VALUES (?,?,?,?)", + (agent_row["id"], user_id, f"Scheduled: {agent_row['name']}", + json.dumps({"workspace_id": agent_row["workspace_id"], "trigger": "schedule"})), + ) + conv_id = cur.lastrowid + conn.execute( + "UPDATE agent_triggers SET last_fired_at=? WHERE id=?", + (now.isoformat(), trig["id"]), + ) + conn.commit() + objective = (agent_row["system_instructions"] or "").strip() or \ + f"Exécute l'agent planifié « {agent_row['name']} »." + # best-effort fire-and-forget (offline/mock path is safe & fast) + try: + engine = AgentEngine(user_id, workspace_id=agent_row["workspace_id"]) + async for _ev in engine.run(conv_id, objective, model=agent_row["model"]): + pass + except Exception as exc: # noqa: BLE001 + logger.warning("Scheduled agent %s failed: %s", agent_row["id"], exc) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.exception("Agent scheduler tick failed") + + +async def _current_user_id(request: Request) -> int | None: + user = await get_current_user(request) + if user and user.get("id"): + return user["id"] + with get_conn() as conn: + row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone() + return row["id"] if row else None + + +async def _workspace_id(request: Request) -> int | None: + user = await get_current_user(request) + if user and user.get("workspace_id"): + return user["workspace_id"] + try: + ws = int(request.query_params.get("workspace_id", 0) or 0) + return ws or None + except (TypeError, ValueError): + return None + + +def _default_agent(conn, user_id: int) -> dict: + row = conn.execute( + "SELECT * FROM agents WHERE agent_type='personal' ORDER BY id LIMIT 1" + ).fetchone() + if row: + return dict(row) + cur = conn.execute( + "INSERT INTO agents (workspace_id, name, agent_type, model, created_by) VALUES (?, 'FlowDeck Agent', 'personal', 'gpt-4o', ?)", + (None, user_id), + ) + conn.commit() + return dict(conn.execute("SELECT * FROM agents WHERE id=?", (cur.lastrowid,)).fetchone()) + + +# ── Agents ── + + +@router.get("") +async def list_agents(request: Request): + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + with get_conn() as conn: + _default_agent(conn, user_id) + rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall() + return {"agents": [dict(r) for r in rows]} + + +@router.post("") +async def create_agent(request: Request): + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + body = await request.json() if request.headers.get("content-type") else {} + name = (body.get("name") or "").strip() or "Custom Agent" + with get_conn() as conn: + try: + cur = conn.execute( + """INSERT INTO agents (workspace_id, name, icon, agent_type, description, + system_instructions, model, scope_json, trigger_json, approval_mode, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + (ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"), + body.get("description", ""), body.get("system_instructions", ""), + body.get("model", "gpt-4o"), + json.dumps(body.get("scope", {})), + json.dumps(body.get("trigger", {})), + body.get("approval_mode", "auto"), user_id), + ) + conn.commit() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}") + return {"id": cur.lastrowid, "name": name, "status": "created"} + + +# ── Conversations ── + + +@router.get("/conversations") +async def list_conversations(request: Request): + user_id = await _current_user_id(request) + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC", + (user_id,), + ).fetchall() + return {"conversations": [dict(r) for r in rows]} + + +@router.post("/conversations") +async def create_conversation(request: Request): + user_id = await _current_user_id(request) + body = await request.json() if request.headers.get("content-type") else {} + ws = await _workspace_id(request) + with get_conn() as conn: + agent = _default_agent(conn, user_id) + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json) + VALUES (?,?,?,?)""", + (agent["id"], user_id, body.get("title", "New conversation"), + json.dumps({"workspace_id": ws})), + ) + conn.commit() + return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"} + + +@router.get("/conversations/{conversation_id}") +async def get_conversation(request: Request, conversation_id: int): + with get_conn() as conn: + conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone() + if not conv: + raise HTTPException(status_code=404, detail="Conversation introuvable") + messages = conn.execute( + "SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id", + (conversation_id,), + ).fetchall() + return {"conversation": dict(conv), "messages": [dict(m) for m in messages]} + + +@router.delete("/conversations/{conversation_id}") +async def delete_conversation(request: Request, conversation_id: int): + with get_conn() as conn: + if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone(): + raise HTTPException(status_code=404, detail="Conversation introuvable") + conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,)) + conn.commit() + return {"id": conversation_id, "status": "deleted"} + + +# ── Run (SSE) ── + + +@router.post("/conversations/{conversation_id}/run") +async def run_conversation(request: Request, conversation_id: int): + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + body = await request.json() if request.headers.get("content-type") else {} + objective = (body.get("message") or "").strip() + if not objective: + raise HTTPException(status_code=400, detail="message est requis") + + with get_conn() as conn: + conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone() + if not conv: + raise HTTPException(status_code=404, detail="Conversation introuvable") + + engine = AgentEngine(user_id, workspace_id=ws) + provider = body.get("provider") or None + llm = LLMClient(provider=provider) if provider else None + if llm: + engine.llm = llm + + async def event_stream(): + async for ev in engine.run( + conversation_id, objective, + model=body.get("model"), + mentions=body.get("mentions"), + files=body.get("files"), + skill_id=body.get("skill_id"), + ): + yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# ── Audit & rollback ── + + +@router.get("/conversations/{conversation_id}/actions") +async def list_actions(request: Request, conversation_id: int): + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id", + (conversation_id,), + ).fetchall() + return {"actions": [dict(r) for r in rows]} + + +@router.post("/actions/{action_id}/undo") +async def undo(request: Request, action_id: int): + try: + undo_action(action_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}") + return {"id": action_id, "status": "reverted"} + + +# ── Skills ── + + +@router.get("/skills") +async def list_skills(request: Request): + ws = await _workspace_id(request) + with get_conn() as conn: + rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall() + return {"skills": [dict(r) for r in rows]} + + +@router.post("/skills") +async def create_skill(request: Request): + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + body = await request.json() if request.headers.get("content-type") else {} + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name est requis") + with get_conn() as conn: + try: + cur = conn.execute( + """INSERT INTO agent_skills (workspace_id, name, description, prompt_template, allowed_tools_json, created_by) + VALUES (?,?,?,?,?,?)""", + (ws, name, body.get("description", ""), body.get("prompt_template", ""), + json.dumps(body.get("allowed_tools", [])), user_id), + ) + conn.commit() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") + return {"id": cur.lastrowid, "name": name, "status": "created"} + + +@router.post("/skills/{skill_id}/apply") +async def apply_skill(request: Request, skill_id: int): + """Create a conversation pre-loaded with a skill, ready to run.""" + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + with get_conn() as conn: + skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not skill: + raise HTTPException(status_code=404, detail="Skill introuvable") + agent = _default_agent(conn, user_id) + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json) + VALUES (?,?,?,?)""", + (agent["id"], user_id, skill["name"], json.dumps({"workspace_id": ws, "skill_id": skill_id})), + ) + conn.commit() + return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"} + + +# ── Triggers & tools ── + + +@router.post("/{agent_id}/trigger") +async def trigger_agent(request: Request, agent_id: int): + """Manually fire a custom agent: create a conversation and run it with the + agent's instructions as the objective (falls back to a generic prompt).""" + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + with get_conn() as conn: + agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not agent: + raise HTTPException(status_code=404, detail="Agent introuvable") + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json) + VALUES (?,?,?,?)""", + (agent_id, user_id, f"Run: {agent['name']}", json.dumps({"workspace_id": ws})), + ) + conv_id = cur.lastrowid + conn.commit() + objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »." + engine = AgentEngine(user_id, workspace_id=ws) + + async def event_stream(): + async for ev in engine.run(conv_id, objective, model=agent["model"]): + yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache"}, + ) + + +@router.get("/tools") +async def list_tools(request: Request): + registry = ToolRegistry() + tools = registry.schema() + return {"tools": tools} + + +@router.get("/providers") +async def list_providers(request: Request): + """Expose configured LLM provider + availability to the UI.""" + llm = LLMClient() + return { + "provider": llm.provider, + "model": llm.default_model, + "available": await llm.is_available(), + "max_iterations": settings.agent_max_iterations, + } + + +# ── Agents by id (registered LAST so static routes /tools, /skills, … win) ── + + +@router.get("/{agent_id}") +async def get_agent(request: Request, agent_id: int): + with get_conn() as conn: + row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Agent introuvable") + return dict(row) + + +@router.put("/{agent_id}") +async def update_agent(request: Request, agent_id: int): + body = await request.json() if request.headers.get("content-type") else {} + with get_conn() as conn: + existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Agent introuvable") + sets, params = [], [] + for col in ("name", "icon", "description", "system_instructions", "model", + "approval_mode", "is_active"): + if col in body: + sets.append(f"{col}=?") + params.append(body[col]) + if "scope" in body: + sets.append("scope_json=?") + params.append(json.dumps(body["scope"])) + if "trigger" in body: + sets.append("trigger_json=?") + params.append(json.dumps(body["trigger"])) + if sets: + params.append(agent_id) + conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + return {"id": agent_id, "status": "updated"} + + +@router.delete("/{agent_id}") +async def delete_agent(request: Request, agent_id: int): + with get_conn() as conn: + existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Agent introuvable") + conn.execute("DELETE FROM agents WHERE id=?", (agent_id,)) + conn.commit() + return {"id": agent_id, "status": "deleted"} \ No newline at end of file diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py new file mode 100644 index 0000000..c1d03b0 --- /dev/null +++ b/app/services/agent_engine.py @@ -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 \ No newline at end of file diff --git a/app/services/context_builder.py b/app/services/context_builder.py new file mode 100644 index 0000000..b8dd155 --- /dev/null +++ b/app/services/context_builder.py @@ -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 + ) \ No newline at end of file diff --git a/app/services/llm_client.py b/app/services/llm_client.py new file mode 100644 index 0000000..179b2ba --- /dev/null +++ b/app/services/llm_client.py @@ -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 " → 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 " 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) \ No newline at end of file diff --git a/app/services/permission_manager.py b/app/services/permission_manager.py new file mode 100644 index 0000000..e74daf9 --- /dev/null +++ b/app/services/permission_manager.py @@ -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") \ No newline at end of file diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py new file mode 100644 index 0000000..9e8989d --- /dev/null +++ b/app/services/tool_registry.py @@ -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) \ No newline at end of file diff --git a/app/templates/agent_message.html b/app/templates/agent_message.html new file mode 100644 index 0000000..f0c042f --- /dev/null +++ b/app/templates/agent_message.html @@ -0,0 +1,17 @@ +{# FlowDeck — Agent message fragment (v4.10.0). Rendered standalone for history. #} +
+
+ {% if role == 'assistant' %}🤖{% else %}👤{% endif %} +
+
+
{{ role }} · {{ model or '' }}
+
{{ content }}
+ {% if tool_calls %} +
    + {% for call in (tool_calls | fromjson_json) %} +
  • 🛠 {{ call.name }} — {{ call.arguments | tojson }}
  • + {% endfor %} +
+ {% endif %} +
+
\ No newline at end of file diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html new file mode 100644 index 0000000..448be00 --- /dev/null +++ b/app/templates/agent_panel.html @@ -0,0 +1,224 @@ +{# ════════ FlowDeck Agent panel (v4.10.0) — sidebar-triggered chat + floating button ════════ #} + + + + +
+
+
🤖 Agent
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+
+ + \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 8d70587..4fe94af 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -362,12 +362,20 @@ @@ -926,6 +934,20 @@ init() { window.appState = this; this.loadSidebarConfig(); + this.loadAgents(); + }, + agentList: [], + loadAgents() { + var self = this; + fetch('/api/agent').then(function(r){ return r.json(); }).then(function(d){ + self.agentList = d.agents || []; + }).catch(function(){}); + }, + agentOpen(id) { + document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { agentId: id || null } })); + }, + agentNew() { + document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }, sidebarCollapsed: false, mobileSidebarOpen: false, @@ -1833,5 +1855,7 @@ +{% include 'agent_panel.html' %} + diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..32ceda0 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,329 @@ +"""FlowDeck — Agent tests (v4.10.0). + +Covers the DB schema, LLM client (offline mock), permission manager, tool +registry (writes + undo snapshots + rollback), the ReAct engine (SSE events) +and the /api/agent/* router (agents, conversations, run, audit, skills). +""" +import asyncio +import json +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network + + from app.main import app + from app.db import init_db, get_conn + from app.password_utils import hash_password + from app.config import settings + + # Ensure settings singleton uses OUR temp DB (not one set by another test file). + settings.database_url = f"sqlite:///{db_path}" + settings.llm_provider = "offline" + settings.agent_max_iterations = 12 + settings.agent_max_tokens_budget = 500_000 + settings.agent_run_timeout_seconds = 30 + + init_db() + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) " + "VALUES ('admin', 'Admin', '', ?, 1)", + (hash_password("test"),), + ) + conn.commit() + + yield TestClient(app) + + try: + os.unlink(db_path) + except FileNotFoundError: + pass + + +def _admin_id() -> int: + from app.db import get_conn + with get_conn() as conn: + row = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone() + return row["id"] if row else 1 + + +def _collect(events): + return [e for e in events] + + +async def _run_engine(engine, *args, **kwargs): + return [e async for e in engine.run(*args, **kwargs)] + + +# ── LLM client (offline mock) ── + +def test_llm_client_offline_mock(client): + from app.services.llm_client import LLMClient + llm = LLMClient(provider="offline") + resp = asyncio.run(llm.complete( + [{"role": "user", "content": "create collection Projets"}], tools=[], + )) + assert resp.tool_calls, "offline mock should detect a create-collection intent" + assert resp.tool_calls[0]["name"] == "create_collection" + assert resp.tool_calls[0]["arguments"]["name"] == "Projets" + + +def test_llm_client_providers_listed(client): + from app.services.llm_client import PROVIDERS + for name in ("openai", "anthropic", "google", "deepseek", "qwencloud", + "nvidia", "openrouter", "ollama", "offline"): + assert name in PROVIDERS + + +def test_llm_client_offline_no_credentials(client): + from app.services.llm_client import LLMClient + llm = LLMClient(provider="openai", api_key="") + resp = asyncio.run(llm.complete( + [{"role": "user", "content": "hello"}], tools=[], + )) + # No key → falls back to mock (a final text answer, no crash) + assert resp.text != "" or resp.tool_calls + + +# ── DB schema ── + +def test_agent_tables_exist(client): + tables = {"agents", "agent_conversations", "agent_messages", + "agent_actions", "agent_skills", "agent_triggers"} + from app.db import get_conn + with get_conn() as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + names = {r["name"] for r in rows} + assert tables.issubset(names) + + +# ── Tools ── + +def test_tool_create_collection_and_search(client): + from app.services.tool_registry import ToolRegistry + reg = ToolRegistry() + res = asyncio.run(reg.execute("create_collection", {"name": "Projets"})) + assert res.status == "success" + cid = res.data["collection_id"] + assert res.undo == {"action": "delete", "table": "collections", "id": cid} + + search = asyncio.run(reg.execute("search_workspace", {"query": "Projets"})) + assert search.status == "success" + assert any(c["id"] == cid for c in search.data["collections"]) + + +def test_tool_create_page_with_properties(client): + from app.services.tool_registry import ToolRegistry + reg = ToolRegistry() + coll = asyncio.run(reg.execute("create_collection", {"name": "Tasks"})) + cid = coll.data["collection_id"] + asyncio.run(reg.execute("add_property", {"collection_id": cid, "name": "Status", "prop_type": "select", "options": ["Todo", "Done"]})) + page = asyncio.run(reg.execute("create_page", {"collection_id": cid, "title": "Task 1", "properties": {"Status": "Todo"}})) + assert page.status == "success" + assert page.target_type == "page" + assert page.data["title"] == "Task 1" + + +def test_tool_unknown_returns_error(client): + from app.services.tool_registry import ToolRegistry + res = asyncio.run(ToolRegistry().execute("nope", {})) + assert res.status == "error" + + +# ── Permissions ── + +def test_permission_manager_roles(client): + from app.services.permission_manager import PermissionManager + from app.db import get_conn + with get_conn() as conn: + owner = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone() + conn.execute("INSERT INTO users (login, is_admin) VALUES ('viewer_user', 0)") + viewer = conn.execute("SELECT id FROM users WHERE login='viewer_user'").fetchone() + conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('ws', ?)", (owner["id"],)) + ws = conn.execute("SELECT id FROM workspaces WHERE name='ws'").fetchone() + conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], viewer["id"], "viewer")) + conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], owner["id"], "owner")) + conn.commit() + + pm_owner = PermissionManager(owner["id"]) + pm_viewer = PermissionManager(viewer["id"]) + assert pm_owner.can_write(ws["id"]) is True + assert pm_viewer.can_write(ws["id"]) is False + + from fastapi import HTTPException + # viewer cannot write + with pytest.raises(HTTPException): + pm_viewer.assert_can("create_page", {}, ws["id"]) + # owner can write + pm_owner.assert_can("create_page", {}, ws["id"]) + # destructive requires confirm mode + with pytest.raises(HTTPException): + pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="auto") + pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="confirm") + + +# ── Engine (ReAct + audit + rollback) ── + +def test_engine_run_creates_collection(client): + from app.services.agent_engine import AgentEngine + from app.db import get_conn + + engine = AgentEngine(_admin_id(), workspace_id=None) + events = asyncio.run(_run_engine(engine, _make_conversation(client), "crée une collection Stats")) + + types = [e["type"] for e in events] + assert "reasoning" in types + assert "action" in types + assert "final" in types + + actions = [e for e in events if e["type"] == "action"] + assert any(a["tool"] == "create_collection" and a["status"] == "success" for a in actions) + + # Collection actually persisted + with get_conn() as conn: + row = conn.execute("SELECT id FROM collections WHERE name='Stats'").fetchone() + assert row is not None + + +def test_engine_audit_and_undo(client): + from app.services.agent_engine import AgentEngine, undo_action + from app.db import get_conn + + conv = _make_conversation(client) + engine = AgentEngine(_admin_id()) + asyncio.run(_run_engine(engine, conv, "créer une collection CRM")) + + with get_conn() as conn: + coll = conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone() + assert coll is not None + action = conn.execute( + "SELECT id FROM agent_actions WHERE tool_name='create_collection' AND target_id=?", + (str(coll["id"]),), + ).fetchone() + assert action is not None + + # undo removes the collection and marks the action reverted + undo_action(action["id"]) + from app.db import get_conn as gc + with gc() as conn: + assert conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone() is None + a = conn.execute("SELECT status FROM agent_actions WHERE id=?", (action["id"],)).fetchone() + assert a["status"] == "reverted" + + +def _make_conversation(client) -> int: + resp = client.post("/api/agent/conversations", json={"title": "test"}) + return resp.json()["id"] + + +# ── Router: agents, conversations, run, skills ── + +def test_router_list_agents(client): + resp = client.get("/api/agent") + assert resp.status_code == 200 + assert resp.json()["agents"] + assert any(a["agent_type"] == "personal" for a in resp.json()["agents"]) + + +def test_router_create_custom_agent(client): + resp = client.post("/api/agent", json={"name": "Reporting", "agent_type": "custom", + "system_instructions": "Produce weekly report"}) + assert resp.status_code == 200 + assert resp.json()["id"] + # list now includes it + agents = client.get("/api/agent").json()["agents"] + assert any(a["name"] == "Reporting" for a in agents) + + +def test_router_conversation_crud(client): + c = client.post("/api/agent/conversations", json={"title": "Hello"}).json() + cid = c["id"] + detail = client.get(f"/api/agent/conversations/{cid}") + assert detail.status_code == 200 + assert detail.json()["conversation"]["id"] == cid + assert client.delete(f"/api/agent/conversations/{cid}").status_code == 200 + assert client.get(f"/api/agent/conversations/{cid}").status_code == 404 + + +def test_router_run_sse_stream(client): + cid = _make_conversation(client) + resp = client.post(f"/api/agent/conversations/{cid}/run", + json={"message": "create collection Roadmap"}) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + body = resp.text + assert "data:" in body + events = [_parse_sse(line) for line in body.strip().split("\n\n") if line.strip()] + types = [e.get("type") for e in events if e] + assert "final" in types + + +def test_router_run_malformed(client): + cid = _make_conversation(client) + resp = client.post(f"/api/agent/conversations/{cid}/run", json={}) + assert resp.status_code == 400 + + +def test_router_actions_and_undo(client): + cid = _make_conversation(client) + client.post(f"/api/agent/conversations/{cid}/run", json={"message": "créer une collection Docs"}) + actions = client.get(f"/api/agent/conversations/{cid}/actions").json()["actions"] + assert actions, "run should have logged actions" + target = actions[0] + assert target["tool_name"] == "create_collection" + # undo via endpoint + r = client.post(f"/api/agent/actions/{target['id']}/undo") + assert r.status_code == 200 + assert r.json()["status"] == "reverted" + + +def test_router_skills(client): + s = client.post("/api/agent/skills", json={"name": "Préparation de sprint", + "prompt_template": "Analyse les issues et crée un sprint", + "allowed_tools": ["read_gitea_issues", "create_collection"]}) + assert s.status_code == 200 + sid = s.json()["id"] + skills = client.get("/api/agent/skills").json()["skills"] + assert any(k["name"] == "Préparation de sprint" for k in skills) + # apply → creates a conversation ready to run + apply = client.post(f"/api/agent/skills/{sid}/apply") + assert apply.status_code == 200 + assert apply.json()["conversation_id"] + + +def test_router_tools_and_providers(client): + tools = client.get("/api/agent/tools").json()["tools"] + assert len(tools) >= 10 + names = {t["name"] for t in tools} + for expected in ("search_workspace", "create_collection", "create_page", + "update_page", "add_property", "create_view", "read_page"): + assert expected in names + + prov = client.get("/api/agent/providers").json() + assert prov["provider"] == "offline" + assert prov["available"] is False + + +def _parse_sse(line: str) -> dict: + for part in line.split("\n"): + if part.startswith("data:"): + try: + return json.loads(part[len("data:"):].strip()) + except json.JSONDecodeError: + return {} + return {} \ No newline at end of file